plain.postgres 0.107.1__tar.gz → 0.108.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 (221) hide show
  1. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/PKG-INFO +1 -1
  2. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/CHANGELOG.md +12 -0
  3. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/checks_structural.py +10 -5
  4. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/state.py +1 -1
  5. plain_postgres-0.108.0/plain/postgres/preflight/__init__.py +2 -0
  6. plain_postgres-0.108.0/plain/postgres/preflight/database.py +158 -0
  7. plain_postgres-0.108.0/plain/postgres/preflight/indexes.py +250 -0
  8. plain_postgres-0.108.0/plain/postgres/preflight/models.py +198 -0
  9. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/pyproject.toml +1 -1
  10. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_diagnose.py +29 -4
  11. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_preflight_duplicate_indexes.py +1 -1
  12. plain_postgres-0.108.0/tests/internal/test_preflight_fk_composite_hint.py +85 -0
  13. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_preflight_fk_coverage.py +73 -1
  14. plain_postgres-0.107.1/plain/postgres/preflight.py +0 -541
  15. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/.gitignore +0 -0
  16. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/CLAUDE.md +0 -0
  17. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/LICENSE +0 -0
  18. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/README.md +0 -0
  19. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/README.md +0 -0
  20. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/__init__.py +0 -0
  21. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/adapters.py +0 -0
  22. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  23. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  24. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/aggregates.py +0 -0
  25. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/base.py +0 -0
  26. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/__init__.py +0 -0
  27. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/converge.py +0 -0
  28. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/core.py +0 -0
  29. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/decorators.py +0 -0
  30. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/diagnose.py +0 -0
  31. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/migrations.py +0 -0
  32. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/schema.py +0 -0
  33. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/cli/sync.py +0 -0
  34. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/config.py +0 -0
  35. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/connection.py +0 -0
  36. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/constants.py +0 -0
  37. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/constraints.py +0 -0
  38. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/convergence/__init__.py +0 -0
  39. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/convergence/analysis.py +0 -0
  40. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/convergence/fixes.py +0 -0
  41. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/convergence/planning.py +0 -0
  42. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/database_url.py +0 -0
  43. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/db.py +0 -0
  44. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/ddl.py +0 -0
  45. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/default_settings.py +0 -0
  46. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/deletion.py +0 -0
  47. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/dialect.py +0 -0
  48. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/entrypoints.py +0 -0
  49. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/enums.py +0 -0
  50. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/exceptions.py +0 -0
  51. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/expressions.py +0 -0
  52. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/__init__.py +0 -0
  53. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/base.py +0 -0
  54. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/binary.py +0 -0
  55. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/boolean.py +0 -0
  56. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/duration.py +0 -0
  57. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/encrypted.py +0 -0
  58. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/json.py +0 -0
  59. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/mixins.py +0 -0
  60. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/network.py +0 -0
  61. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/numeric.py +0 -0
  62. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/primary_key.py +0 -0
  63. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/related.py +0 -0
  64. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/related_descriptors.py +0 -0
  65. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/related_lookups.py +0 -0
  66. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/related_managers.py +0 -0
  67. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  68. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/reverse_related.py +0 -0
  69. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/temporal.py +0 -0
  70. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/text.py +0 -0
  71. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/timezones.py +0 -0
  72. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/fields/uuid.py +0 -0
  73. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/forms.py +0 -0
  74. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/__init__.py +0 -0
  75. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/comparison.py +0 -0
  76. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/datetime.py +0 -0
  77. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/math.py +0 -0
  78. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/mixins.py +0 -0
  79. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/random.py +0 -0
  80. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/text.py +0 -0
  81. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/uuid.py +0 -0
  82. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/functions/window.py +0 -0
  83. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/indexes.py +0 -0
  84. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/__init__.py +0 -0
  85. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/__init__.py +0 -0
  86. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  87. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  88. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/context.py +0 -0
  89. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/helpers.py +0 -0
  90. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/ownership.py +0 -0
  91. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/runner.py +0 -0
  92. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/health/types.py +0 -0
  93. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/introspection/schema.py +0 -0
  94. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/lookups.py +0 -0
  95. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/meta.py +0 -0
  96. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/middleware.py +0 -0
  97. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/__init__.py +0 -0
  98. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/autodetector.py +0 -0
  99. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/exceptions.py +0 -0
  100. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/executor.py +0 -0
  101. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/graph.py +0 -0
  102. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/loader.py +0 -0
  103. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/migration.py +0 -0
  104. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  105. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/base.py +0 -0
  106. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/fields.py +0 -0
  107. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/models.py +0 -0
  108. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/special.py +0 -0
  109. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/optimizer.py +0 -0
  110. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/questioner.py +0 -0
  111. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/recorder.py +0 -0
  112. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/serializer.py +0 -0
  113. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/utils.py +0 -0
  114. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/migrations/writer.py +0 -0
  115. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/options.py +0 -0
  116. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/otel.py +0 -0
  117. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/query.py +0 -0
  118. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/query_utils.py +0 -0
  119. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/registry.py +0 -0
  120. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/schema.py +0 -0
  121. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sources.py +0 -0
  122. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sql/__init__.py +0 -0
  123. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sql/compiler.py +0 -0
  124. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sql/constants.py +0 -0
  125. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sql/datastructures.py +0 -0
  126. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sql/query.py +0 -0
  127. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/sql/where.py +0 -0
  128. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/test/__init__.py +0 -0
  129. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/test/database.py +0 -0
  130. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/test/pytest.py +0 -0
  131. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/transaction.py +0 -0
  132. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/types.py +0 -0
  133. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/types.pyi +0 -0
  134. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/plain/postgres/utils.py +0 -0
  135. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/forms.py +0 -0
  136. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  137. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  138. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  139. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  140. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  141. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  142. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  143. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  144. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  145. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  146. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  147. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  148. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  149. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  150. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  151. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  152. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  153. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  154. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/migrations/__init__.py +0 -0
  155. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/__init__.py +0 -0
  156. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/constraints.py +0 -0
  157. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/defaults.py +0 -0
  158. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/delete.py +0 -0
  159. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/encrypted.py +0 -0
  160. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/forms.py +0 -0
  161. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/indexes.py +0 -0
  162. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/iteration.py +0 -0
  163. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/mixins.py +0 -0
  164. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/nullability.py +0 -0
  165. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/querysets.py +0 -0
  166. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/relationships.py +0 -0
  167. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/storage_parameters.py +0 -0
  168. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/trees.py +0 -0
  169. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/models/unregistered.py +0 -0
  170. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/urls.py +0 -0
  171. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/examples/views.py +0 -0
  172. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/settings.py +0 -0
  173. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/app/urls.py +0 -0
  174. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/conftest.py +0 -0
  175. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/conftest_convergence.py +0 -0
  176. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  177. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_autodetector_type_change.py +0 -0
  178. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_connection_isolation.py +0 -0
  179. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_connection_lifecycle.py +0 -0
  180. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_connection_pool.py +0 -0
  181. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_constraint_violation_error.py +0 -0
  182. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence.py +0 -0
  183. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_constraints.py +0 -0
  184. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_defaults.py +0 -0
  185. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_fk.py +0 -0
  186. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_indexes.py +0 -0
  187. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_nullability.py +0 -0
  188. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  189. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_convergence_timeouts.py +0 -0
  190. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_db_expression_defaults.py +0 -0
  191. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_executor_connection_hook.py +0 -0
  192. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_fk_characterization.py +0 -0
  193. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_health.py +0 -0
  194. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_introspection.py +0 -0
  195. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_literal_default_persistence.py +0 -0
  196. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_management_connection.py +0 -0
  197. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_migration_executor.py +0 -0
  198. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_no_callable_defaults.py +0 -0
  199. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_otel_metrics.py +0 -0
  200. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_schema_normalize_type.py +0 -0
  201. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/internal/test_schema_timeouts.py +0 -0
  202. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_create_update.py +0 -0
  203. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_database_url.py +0 -0
  204. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_deferred_loading.py +0 -0
  205. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_delete_behaviors.py +0 -0
  206. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_encrypted_fields.py +0 -0
  207. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_exceptions.py +0 -0
  208. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_field_defaults.py +0 -0
  209. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_functions_uuid.py +0 -0
  210. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_integrity_error_mapping.py +0 -0
  211. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_iterator.py +0 -0
  212. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_m2m.py +0 -0
  213. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_manager_assignment.py +0 -0
  214. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_manual_pk.py +0 -0
  215. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_mixins.py +0 -0
  216. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_modelform_roundtrip.py +0 -0
  217. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_queryset_repr.py +0 -0
  218. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_random_string_field.py +0 -0
  219. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_raw_query.py +0 -0
  220. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/tests/public/test_read_only_transactions.py +0 -0
  221. {plain_postgres-0.107.1 → plain_postgres-0.108.0}/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.107.1
3
+ Version: 0.108.0
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.108.0](https://github.com/dropseed/plain/releases/plain-postgres@0.108.0) (2026-06-09)
4
+
5
+ ### What's changed
6
+
7
+ - A partial index of exactly `WHERE <fk> IS NOT NULL` on the FK column now counts as FK index coverage — every FK lookup and referencing-side sweep is a `WHERE fk = ?`, which implies the predicate, so Postgres can always use it. Both the `postgres.missing_fk_indexes` preflight check (an `Index`/`UniqueConstraint` with `condition=Q(fk__isnull=False)`) and the doctor's missing-FK check (matching the live index predicate) accept it; any other partial predicate still doesn't count. ([4836069767](https://github.com/dropseed/plain/commit/4836069767))
8
+ - The missing-FK-index warning now points out when an existing composite index or constraint already contains the FK at a non-leading position, suggesting a reorder to lead with the FK instead of adding a new index (safe when every query using the composite filters all of its columns with equality). ([084b6abba5](https://github.com/dropseed/plain/commit/084b6abba5))
9
+ - Internal: the preflight checks moved from a single `preflight.py` into a `preflight/` package (`models.py`, `database.py`, `indexes.py`). Check names and behavior are unchanged. ([edc792d358](https://github.com/dropseed/plain/commit/edc792d358))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - No changes required.
14
+
3
15
  ## [0.107.1](https://github.com/dropseed/plain/releases/plain-postgres@0.107.1) (2026-06-08)
4
16
 
5
17
  ### What's changed
@@ -192,10 +192,11 @@ def check_missing_fk_indexes(
192
192
  Partial indexes (``WHERE`` clause set on ``pg_index.indpred``) don't
193
193
  count: Postgres only uses them for queries that imply the partial
194
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.
195
+ still sequential-scan. The one exception is a predicate of exactly
196
+ ``<fk> IS NOT NULL`` on the FK column itselfevery FK lookup and
197
+ referencing-side sweep is a ``WHERE fk = ?``, which implies
198
+ ``fk IS NOT NULL``, so Postgres can always use that partial. Match
199
+ the preflight's coverage rule.
199
200
  """
200
201
  cursor.execute("""
201
202
  SELECT
@@ -216,7 +217,11 @@ def check_missing_fk_indexes(
216
217
  FROM pg_catalog.pg_index i
217
218
  WHERE i.indrelid = c.conrelid
218
219
  AND i.indkey[0] = c.conkey[1]
219
- AND i.indpred IS NULL
220
+ AND (
221
+ i.indpred IS NULL
222
+ OR pg_get_expr(i.indpred, i.indrelid) =
223
+ format('(%s IS NOT NULL)', quote_ident(a.attname))
224
+ )
220
225
  )
221
226
  ORDER BY ct.relname, a.attname
222
227
  """)
@@ -550,7 +550,7 @@ class StateModelsRegistry(ModelsRegistry):
550
550
  self.ready = True
551
551
 
552
552
  # There shouldn't be any operations pending at this point.
553
- from plain.postgres.preflight import _check_lazy_references
553
+ from plain.postgres.preflight.models import _check_lazy_references
554
554
 
555
555
  if errors := _check_lazy_references(self, packages_registry):
556
556
  raise ValueError("\n".join(error.fix for error in errors))
@@ -0,0 +1,2 @@
1
+ # Import these to force registration of checks
2
+ from . import database, indexes, models # NOQA
@@ -0,0 +1,158 @@
1
+ """Preflight checks on the database connection and migration state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from plain.postgres.db import get_connection
6
+ from plain.preflight import PreflightCheck, PreflightResult, register_check
7
+ from plain.runtime import settings
8
+
9
+
10
+ @register_check("postgres.middleware_installed")
11
+ class CheckMiddlewareInstalled(PreflightCheck):
12
+ """Errors if `DatabaseConnectionMiddleware` isn't in `MIDDLEWARE`.
13
+
14
+ Without it, pooled connections are only released by GC at the end of
15
+ each request — relying on refcount timing under load is a recipe for
16
+ pool exhaustion under cyclic refs or delayed finalization.
17
+ """
18
+
19
+ REQUIRED = "plain.postgres.DatabaseConnectionMiddleware"
20
+
21
+ def run(self) -> list[PreflightResult]:
22
+ if self.REQUIRED in settings.MIDDLEWARE:
23
+ return []
24
+ return [
25
+ PreflightResult(
26
+ fix=(
27
+ f"Add '{self.REQUIRED}' to MIDDLEWARE so pooled "
28
+ "database connections are returned at the end of each "
29
+ "request. Place it first so its after_response runs "
30
+ "after any middleware that queries the database."
31
+ ),
32
+ id="postgres.middleware_not_installed",
33
+ )
34
+ ]
35
+
36
+
37
+ @register_check("postgres.postgres_version")
38
+ class CheckPostgresVersion(PreflightCheck):
39
+ """Checks that the PostgreSQL server meets the minimum version requirement."""
40
+
41
+ MINIMUM_VERSION = 16
42
+
43
+ def run(self) -> list[PreflightResult]:
44
+ conn = get_connection()
45
+ conn.ensure_connection()
46
+ assert conn.connection is not None
47
+ major, minor = divmod(conn.connection.info.server_version, 10000)
48
+ if major < self.MINIMUM_VERSION:
49
+ return [
50
+ PreflightResult(
51
+ fix=f"PostgreSQL {self.MINIMUM_VERSION} or later is required (found {major}.{minor}).",
52
+ id="postgres.postgres_version_too_old",
53
+ )
54
+ ]
55
+ return []
56
+
57
+
58
+ @register_check("postgres.database_tables")
59
+ class CheckDatabaseTables(PreflightCheck):
60
+ """Checks for unknown tables in the database when plain.postgres is available."""
61
+
62
+ def run(self) -> list[PreflightResult]:
63
+ from plain.postgres.introspection import get_unknown_tables
64
+
65
+ unknown_tables = get_unknown_tables()
66
+
67
+ if not unknown_tables:
68
+ return []
69
+
70
+ table_names = ", ".join(unknown_tables)
71
+ return [
72
+ PreflightResult(
73
+ fix=f"Unknown tables in default database: {table_names}. "
74
+ "Tables may be from packages/models that have been uninstalled. "
75
+ "Make sure you have a backup, then run `plain postgres drop-unknown-tables` to remove them.",
76
+ id="postgres.unknown_database_tables",
77
+ warning=True,
78
+ )
79
+ ]
80
+
81
+
82
+ @register_check("postgres.prunable_migrations")
83
+ class CheckPrunableMigrations(PreflightCheck):
84
+ """Warns about stale migration records in the database."""
85
+
86
+ def run(self) -> list[PreflightResult]:
87
+ # Import here to avoid circular import issues
88
+ from plain.postgres.migrations.loader import MigrationLoader
89
+ from plain.postgres.migrations.recorder import MigrationRecorder
90
+
91
+ errors = []
92
+
93
+ # Load migrations from disk and database
94
+ conn = get_connection()
95
+ loader = MigrationLoader(conn, ignore_no_migrations=True)
96
+ recorder = MigrationRecorder(conn)
97
+ recorded_migrations = recorder.applied_migrations()
98
+
99
+ # disk_migrations should not be None after MigrationLoader initialization,
100
+ # but check to satisfy type checker
101
+ if loader.disk_migrations is None:
102
+ return errors
103
+
104
+ # Find all prunable migrations (recorded but not on disk)
105
+ all_prunable = [
106
+ migration
107
+ for migration in recorded_migrations
108
+ if migration not in loader.disk_migrations
109
+ ]
110
+
111
+ if not all_prunable:
112
+ return errors
113
+
114
+ # Separate into existing packages vs orphaned packages
115
+ existing_packages = set(loader.migrated_packages)
116
+ prunable_existing: list[tuple[str, str]] = []
117
+ prunable_orphaned: list[tuple[str, str]] = []
118
+
119
+ for migration in all_prunable:
120
+ package, name = migration
121
+ if package in existing_packages:
122
+ prunable_existing.append(migration)
123
+ else:
124
+ prunable_orphaned.append(migration)
125
+
126
+ # Build the warning message
127
+ total_count = len(all_prunable)
128
+ message_parts = [
129
+ f"Found {total_count} stale migration record{'s' if total_count != 1 else ''} in the database."
130
+ ]
131
+
132
+ if prunable_existing:
133
+ existing_list = ", ".join(
134
+ f"{pkg}.{name}" for pkg, name in prunable_existing[:3]
135
+ )
136
+ if len(prunable_existing) > 3:
137
+ existing_list += f" (and {len(prunable_existing) - 3} more)"
138
+ message_parts.append(f"From existing packages: {existing_list}.")
139
+
140
+ if prunable_orphaned:
141
+ orphaned_list = ", ".join(
142
+ f"{pkg}.{name}" for pkg, name in prunable_orphaned[:3]
143
+ )
144
+ if len(prunable_orphaned) > 3:
145
+ orphaned_list += f" (and {len(prunable_orphaned) - 3} more)"
146
+ message_parts.append(f"From removed packages: {orphaned_list}.")
147
+
148
+ message_parts.append("Run 'plain migrations prune' to review and remove them.")
149
+
150
+ errors.append(
151
+ PreflightResult(
152
+ fix=" ".join(message_parts),
153
+ id="postgres.prunable_migrations",
154
+ warning=True,
155
+ )
156
+ )
157
+
158
+ return errors
@@ -0,0 +1,250 @@
1
+ """Preflight checks for index coverage on app models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from plain.packages import packages_registry
8
+ from plain.postgres.constraints import UniqueConstraint
9
+ from plain.postgres.expressions import F, OrderBy
10
+ from plain.postgres.fields.related import ForeignKeyField
11
+ from plain.postgres.query_utils import Q
12
+ from plain.postgres.registry import models_registry
13
+ from plain.preflight import PreflightCheck, PreflightResult, register_check
14
+
15
+
16
+ def _get_app_models() -> list[Any]:
17
+ """Return models from the user's app packages only (not framework/third-party)."""
18
+ app_models = []
19
+ for package_config in packages_registry.get_package_configs():
20
+ if package_config.name.startswith("app."):
21
+ app_models.extend(
22
+ models_registry.get_models(package_label=package_config.package_label)
23
+ )
24
+ return app_models
25
+
26
+
27
+ def _collect_model_indexes(model: Any) -> list[tuple[str, list[str], bool]]:
28
+ """Collect (name, fields, is_unique) for non-partial indexes/constraints.
29
+
30
+ Partials are skipped: they only apply to rows matching their predicate,
31
+ so they're never interchangeable with a full index for duplicate
32
+ detection or reorder suggestions.
33
+ """
34
+ all_indexes: list[tuple[str, list[str], bool]] = []
35
+
36
+ for index in model.model_options.indexes:
37
+ if index.fields and not index.is_partial:
38
+ fields = [f.lstrip("-") for f in index.fields]
39
+ all_indexes.append((index.name, fields, False))
40
+
41
+ for constraint in model.model_options.constraints:
42
+ if (
43
+ isinstance(constraint, UniqueConstraint)
44
+ and constraint.fields
45
+ and not constraint.is_partial
46
+ ):
47
+ all_indexes.append((constraint.name, list(constraint.fields), True))
48
+
49
+ return all_indexes
50
+
51
+
52
+ def _bare_column_name(expr: Any) -> str | None:
53
+ """Return the column name if `expr` resolves to a bare column, else `None`.
54
+
55
+ Postgres can range-scan the leading column of an index for `WHERE col = ?`
56
+ only when that column is a real attribute, not an expression — so a
57
+ compound leading expression like `Lower("email")` returns `None` here.
58
+ Sort direction (`F("col").desc()` / `OrderBy(F)`) doesn't affect equality
59
+ lookups, so we unwrap one layer of `OrderBy` around a bare `F`.
60
+ """
61
+ if isinstance(expr, OrderBy):
62
+ expr = expr.expression
63
+ if isinstance(expr, F):
64
+ return expr.name
65
+ return None
66
+
67
+
68
+ def _leading_field_name(
69
+ fields: tuple[str, ...] | list[str], expressions: tuple
70
+ ) -> str | None:
71
+ """The leading column's field name, or `None` if it's an expression."""
72
+ if fields:
73
+ return fields[0].lstrip("-")
74
+ if expressions:
75
+ return _bare_column_name(expressions[0])
76
+ return None
77
+
78
+
79
+ def _condition_is_not_null_on(condition: Q, field_name: str) -> bool:
80
+ """True if `condition` is exactly ``Q(<field_name>__isnull=False)``."""
81
+ return (
82
+ not condition.negated
83
+ and len(condition.children) == 1
84
+ and condition.children[0] == (f"{field_name}__isnull", False)
85
+ )
86
+
87
+
88
+ def _fk_covered_field_names(model: Any) -> set[str]:
89
+ """Field names that appear as the leading column of an index or unique
90
+ constraint — covering arbitrary FK lookups via the index's leading
91
+ column. Includes expression-based indexes/constraints whose leading
92
+ expression is a bare ``F(field_name)``.
93
+
94
+ Partial indexes/constraints (declared with ``condition=Q(...)``) are
95
+ excluded: Postgres can only use them for queries whose predicate
96
+ implies the partial-index predicate, so an FK lookup or cascade
97
+ delete that doesn't filter by that condition still does a sequential
98
+ scan. The one exception is a predicate of exactly
99
+ ``Q(<fk>__isnull=False)`` on the leading FK itself — every FK lookup
100
+ and referencing-side sweep is a ``WHERE fk = ?``, which implies
101
+ ``fk IS NOT NULL``, so Postgres can always use that partial. Match
102
+ the doctor's coverage rule in
103
+ ``introspection/health/checks_structural.py``.
104
+ """
105
+ covered: set[str] = set()
106
+
107
+ def _record(index_or_constraint: Any) -> None:
108
+ leading = _leading_field_name(
109
+ index_or_constraint.fields, index_or_constraint.expressions
110
+ )
111
+ if leading is None:
112
+ return
113
+ if not index_or_constraint.is_partial or _condition_is_not_null_on(
114
+ index_or_constraint.condition, leading
115
+ ):
116
+ covered.add(leading)
117
+
118
+ for index in model.model_options.indexes:
119
+ _record(index)
120
+
121
+ for constraint in model.model_options.constraints:
122
+ if isinstance(constraint, UniqueConstraint):
123
+ _record(constraint)
124
+
125
+ return covered
126
+
127
+
128
+ def _composite_containing(model: Any, field_name: str) -> tuple[str, list[str]] | None:
129
+ """First non-partial index/constraint with `field_name` at a non-leading position.
130
+
131
+ A non-leading column doesn't cover the FK, but reordering the composite to
132
+ lead with it often can — worth suggesting in the fix message.
133
+ """
134
+ for name, fields, _is_unique in _collect_model_indexes(model):
135
+ if field_name in fields[1:]:
136
+ return name, fields
137
+ return None
138
+
139
+
140
+ @register_check("postgres.missing_fk_indexes")
141
+ class CheckMissingFKIndexes(PreflightCheck):
142
+ """Warns about foreign key fields without index coverage."""
143
+
144
+ def run(self) -> list[PreflightResult]:
145
+ results = []
146
+
147
+ for model in _get_app_models():
148
+ covered_fields = _fk_covered_field_names(model)
149
+
150
+ for field in model._model_meta.local_fields:
151
+ if (
152
+ isinstance(field, ForeignKeyField)
153
+ and not field.primary_key
154
+ and field.name not in covered_fields
155
+ ):
156
+ fix = (
157
+ f"Foreign key '{field.name}' has no index coverage. "
158
+ f"Add an Index on [\"{field.name}\"] or a constraint with '{field.name}' as the first field."
159
+ )
160
+
161
+ if composite := _composite_containing(model, field.name):
162
+ composite_name, composite_fields = composite
163
+ fix += (
164
+ f" Alternatively, '{composite_name}' on [{', '.join(composite_fields)}] "
165
+ f"already includes '{field.name}' — reordering it to put '{field.name}' first "
166
+ f"covers this FK without a new index (safe when every query using it "
167
+ f"filters all of its columns with equality)."
168
+ )
169
+
170
+ results.append(
171
+ PreflightResult(
172
+ fix=fix,
173
+ obj=f"{model.model_options.label}.{field.name}",
174
+ id="postgres.missing_fk_index",
175
+ warning=True,
176
+ )
177
+ )
178
+
179
+ return results
180
+
181
+
182
+ @register_check("postgres.duplicate_indexes")
183
+ class CheckDuplicateIndexes(PreflightCheck):
184
+ """Warns about indexes redundant with other indexes or constraints.
185
+
186
+ Catches both prefix-redundancy (a 1-column index shadowed by a wider
187
+ composite) and exact-column duplicates (an `Index(fields=["x"])` that
188
+ duplicates a same-column `UniqueConstraint`).
189
+ """
190
+
191
+ def run(self) -> list[PreflightResult]:
192
+ results = []
193
+
194
+ for model in _get_app_models():
195
+ all_indexes = _collect_model_indexes(model)
196
+
197
+ flagged: set[str] = set()
198
+ for i, idx_a in enumerate(all_indexes):
199
+ for idx_b in all_indexes[i + 1 :]:
200
+ for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
201
+ s_name, s_fields, s_unique = shorter
202
+ l_name, l_fields, l_unique = longer
203
+
204
+ if s_name in flagged:
205
+ continue
206
+
207
+ is_prefix_dup = (
208
+ not s_unique
209
+ and len(s_fields) < len(l_fields)
210
+ and l_fields[: len(s_fields)] == s_fields
211
+ )
212
+ is_exact_dup = (
213
+ s_fields == l_fields
214
+ and not s_unique
215
+ and (l_unique or s_name > l_name)
216
+ )
217
+
218
+ if not (is_prefix_dup or is_exact_dup):
219
+ continue
220
+
221
+ if is_prefix_dup:
222
+ fix = (
223
+ f"Index '{s_name}' on [{', '.join(s_fields)}] "
224
+ f"is redundant with '{l_name}' on [{', '.join(l_fields)}]. "
225
+ f"The longer index covers the same queries."
226
+ )
227
+ elif l_unique:
228
+ fix = (
229
+ f"Index '{s_name}' on [{', '.join(s_fields)}] "
230
+ f"is redundant with '{l_name}' on the same columns. "
231
+ f"The unique-backed index already covers these queries."
232
+ )
233
+ else:
234
+ fix = (
235
+ f"Index '{s_name}' on [{', '.join(s_fields)}] "
236
+ f"is an exact duplicate of '{l_name}'. "
237
+ f"Drop one of them."
238
+ )
239
+
240
+ results.append(
241
+ PreflightResult(
242
+ fix=fix,
243
+ obj=model.model_options.label,
244
+ id="postgres.duplicate_index",
245
+ warning=True,
246
+ )
247
+ )
248
+ flagged.add(s_name)
249
+
250
+ return results
@@ -0,0 +1,198 @@
1
+ """Preflight checks on model definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections import defaultdict
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from plain.packages import packages_registry
11
+ from plain.postgres.registry import ModelsRegistry, models_registry
12
+ from plain.preflight import PreflightCheck, PreflightResult, register_check
13
+
14
+
15
+ @register_check("postgres.all_models")
16
+ class CheckAllModels(PreflightCheck):
17
+ """Validates all model definitions for common issues."""
18
+
19
+ def run(self) -> list[PreflightResult]:
20
+ db_table_models = defaultdict(list)
21
+ # Indexes and constraints share the same Postgres namespace,
22
+ # so track them together to catch cross-type collisions.
23
+ relation_names = defaultdict(list)
24
+ errors = []
25
+ models = models_registry.get_models()
26
+ for model in models:
27
+ db_table_models[model.model_options.db_table].append(
28
+ model.model_options.label
29
+ )
30
+ if not inspect.ismethod(model.preflight):
31
+ errors.append(
32
+ PreflightResult(
33
+ fix=f"The '{model.__name__}.preflight()' class method is currently overridden by {model.preflight!r}.",
34
+ obj=model,
35
+ id="postgres.preflight_method_overridden",
36
+ )
37
+ )
38
+ else:
39
+ errors.extend(model.preflight())
40
+ for model_index in model.model_options.indexes:
41
+ relation_names[model_index.name].append(model.model_options.label)
42
+ for model_constraint in model.model_options.constraints:
43
+ relation_names[model_constraint.name].append(model.model_options.label)
44
+ for db_table, model_labels in db_table_models.items():
45
+ if len(model_labels) != 1:
46
+ model_labels_str = ", ".join(model_labels)
47
+ errors.append(
48
+ PreflightResult(
49
+ fix=f"db_table '{db_table}' is used by multiple models: {model_labels_str}.",
50
+ obj=db_table,
51
+ id="postgres.duplicate_db_table",
52
+ )
53
+ )
54
+ for relation_name, model_labels in relation_names.items():
55
+ if len(model_labels) > 1:
56
+ unique_models = set(model_labels)
57
+ single_model = len(unique_models) == 1
58
+ errors.append(
59
+ PreflightResult(
60
+ fix="index/constraint name '{}' is not unique {} {}.".format(
61
+ relation_name,
62
+ "for model" if single_model else "among models:",
63
+ ", ".join(sorted(unique_models)),
64
+ ),
65
+ id="postgres.relation_name_not_unique_single"
66
+ if single_model
67
+ else "postgres.relation_name_not_unique_multiple",
68
+ ),
69
+ )
70
+ return errors
71
+
72
+
73
+ def _check_lazy_references(
74
+ models_registry: ModelsRegistry, packages_registry: Any
75
+ ) -> list[PreflightResult]:
76
+ """
77
+ Ensure all lazy (i.e. string) model references have been resolved.
78
+
79
+ Lazy references are used in various places throughout Plain, primarily in
80
+ related fields and model signals. Identify those common cases and provide
81
+ more helpful error messages for them.
82
+ """
83
+ pending_models = set(models_registry._pending_operations)
84
+
85
+ # Short circuit if there aren't any errors.
86
+ if not pending_models:
87
+ return []
88
+
89
+ def extract_operation(
90
+ obj: Any,
91
+ ) -> tuple[Callable[..., Any], list[Any], dict[str, Any]]:
92
+ """
93
+ Take a callable found in Packages._pending_operations and identify the
94
+ original callable passed to Packages.lazy_model_operation(). If that
95
+ callable was a partial, return the inner, non-partial function and
96
+ any arguments and keyword arguments that were supplied with it.
97
+
98
+ obj is a callback defined locally in Packages.lazy_model_operation() and
99
+ annotated there with a `func` attribute so as to imitate a partial.
100
+ """
101
+ operation, args, keywords = obj, [], {}
102
+ while hasattr(operation, "func"):
103
+ args.extend(getattr(operation, "args", []))
104
+ keywords.update(getattr(operation, "keywords", {}))
105
+ operation = operation.func
106
+ return operation, args, keywords
107
+
108
+ def app_model_error(model_key: tuple[str, str]) -> str:
109
+ try:
110
+ packages_registry.get_package_config(model_key[0])
111
+ model_error = "app '{}' doesn't provide model '{}'".format(*model_key)
112
+ except LookupError:
113
+ model_error = f"app '{model_key[0]}' isn't installed"
114
+ return model_error
115
+
116
+ # Here are several functions which return CheckMessage instances for the
117
+ # most common usages of lazy operations throughout Plain. These functions
118
+ # take the model that was being waited on as an (package_label, modelname)
119
+ # pair, the original lazy function, and its positional and keyword args as
120
+ # determined by extract_operation().
121
+
122
+ def field_error(
123
+ model_key: tuple[str, str],
124
+ func: Callable[..., Any],
125
+ args: list[Any],
126
+ keywords: dict[str, Any],
127
+ ) -> PreflightResult:
128
+ error_msg = (
129
+ "The field %(field)s was declared with a lazy reference "
130
+ "to '%(model)s', but %(model_error)s."
131
+ )
132
+ params = {
133
+ "model": ".".join(model_key),
134
+ "field": keywords["field"],
135
+ "model_error": app_model_error(model_key),
136
+ }
137
+ return PreflightResult(
138
+ fix=error_msg % params,
139
+ obj=keywords["field"],
140
+ id="fields.lazy_reference_not_resolvable",
141
+ )
142
+
143
+ def default_error(
144
+ model_key: tuple[str, str],
145
+ func: Callable[..., Any],
146
+ args: list[Any],
147
+ keywords: dict[str, Any],
148
+ ) -> PreflightResult:
149
+ error_msg = (
150
+ "%(op)s contains a lazy reference to %(model)s, but %(model_error)s."
151
+ )
152
+ params = {
153
+ "op": func,
154
+ "model": ".".join(model_key),
155
+ "model_error": app_model_error(model_key),
156
+ }
157
+ return PreflightResult(
158
+ fix=error_msg % params,
159
+ obj=func,
160
+ id="postgres.lazy_reference_resolution_failed",
161
+ )
162
+
163
+ # Maps common uses of lazy operations to corresponding error functions
164
+ # defined above. If a key maps to None, no error will be produced.
165
+ # default_error() will be used for usages that don't appear in this dict.
166
+ known_lazy = {
167
+ ("plain.postgres.fields.related", "resolve_related_class"): field_error,
168
+ }
169
+
170
+ def build_error(
171
+ model_key: tuple[str, str],
172
+ func: Callable[..., Any],
173
+ args: list[Any],
174
+ keywords: dict[str, Any],
175
+ ) -> PreflightResult | None:
176
+ key = (func.__module__, func.__name__) # ty: ignore[unresolved-attribute]
177
+ error_fn = known_lazy.get(key, default_error)
178
+ return error_fn(model_key, func, args, keywords) if error_fn else None
179
+
180
+ return sorted(
181
+ filter(
182
+ None,
183
+ (
184
+ build_error(model_key, *extract_operation(func))
185
+ for model_key in pending_models
186
+ for func in models_registry._pending_operations[model_key]
187
+ ),
188
+ ),
189
+ key=lambda error: error.fix,
190
+ )
191
+
192
+
193
+ @register_check("postgres.lazy_references")
194
+ class CheckLazyReferences(PreflightCheck):
195
+ """Ensures all lazy (string) model references have been resolved."""
196
+
197
+ def run(self) -> list[PreflightResult]:
198
+ return _check_lazy_references(models_registry, packages_registry)