plain.postgres 0.99.0__tar.gz → 0.99.1__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 (204) hide show
  1. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/PKG-INFO +1 -1
  2. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/CHANGELOG.md +10 -0
  3. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/checks_structural.py +32 -17
  4. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/pyproject.toml +1 -1
  5. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_diagnose.py +168 -0
  6. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/.gitignore +0 -0
  7. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/CLAUDE.md +0 -0
  8. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/LICENSE +0 -0
  9. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/README.md +0 -0
  10. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/README.md +0 -0
  11. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/__init__.py +0 -0
  12. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/adapters.py +0 -0
  13. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  14. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  15. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/aggregates.py +0 -0
  16. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/base.py +0 -0
  17. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/__init__.py +0 -0
  18. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/converge.py +0 -0
  19. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/core.py +0 -0
  20. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/decorators.py +0 -0
  21. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/diagnose.py +0 -0
  22. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/migrations.py +0 -0
  23. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/schema.py +0 -0
  24. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/cli/sync.py +0 -0
  25. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/config.py +0 -0
  26. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/connection.py +0 -0
  27. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/constants.py +0 -0
  28. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/constraints.py +0 -0
  29. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/convergence/__init__.py +0 -0
  30. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/convergence/analysis.py +0 -0
  31. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/convergence/fixes.py +0 -0
  32. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/convergence/planning.py +0 -0
  33. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/database_url.py +0 -0
  34. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/db.py +0 -0
  35. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/ddl.py +0 -0
  36. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/default_settings.py +0 -0
  37. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/deletion.py +0 -0
  38. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/dialect.py +0 -0
  39. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/entrypoints.py +0 -0
  40. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/enums.py +0 -0
  41. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/exceptions.py +0 -0
  42. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/expressions.py +0 -0
  43. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/__init__.py +0 -0
  44. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/base.py +0 -0
  45. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/binary.py +0 -0
  46. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/boolean.py +0 -0
  47. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/duration.py +0 -0
  48. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/encrypted.py +0 -0
  49. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/json.py +0 -0
  50. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/mixins.py +0 -0
  51. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/network.py +0 -0
  52. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/numeric.py +0 -0
  53. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/primary_key.py +0 -0
  54. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/related.py +0 -0
  55. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/related_descriptors.py +0 -0
  56. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/related_lookups.py +0 -0
  57. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/related_managers.py +0 -0
  58. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/reverse_descriptors.py +0 -0
  59. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/reverse_related.py +0 -0
  60. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/temporal.py +0 -0
  61. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/text.py +0 -0
  62. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/timezones.py +0 -0
  63. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/fields/uuid.py +0 -0
  64. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/forms.py +0 -0
  65. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/__init__.py +0 -0
  66. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/comparison.py +0 -0
  67. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/datetime.py +0 -0
  68. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/math.py +0 -0
  69. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/mixins.py +0 -0
  70. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/random.py +0 -0
  71. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/text.py +0 -0
  72. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/uuid.py +0 -0
  73. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/functions/window.py +0 -0
  74. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/indexes.py +0 -0
  75. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/__init__.py +0 -0
  76. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/__init__.py +0 -0
  77. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  78. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  79. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/context.py +0 -0
  80. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/helpers.py +0 -0
  81. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/ownership.py +0 -0
  82. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/runner.py +0 -0
  83. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/health/types.py +0 -0
  84. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/introspection/schema.py +0 -0
  85. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/lookups.py +0 -0
  86. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/meta.py +0 -0
  87. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/middleware.py +0 -0
  88. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/__init__.py +0 -0
  89. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/autodetector.py +0 -0
  90. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/exceptions.py +0 -0
  91. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/executor.py +0 -0
  92. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/graph.py +0 -0
  93. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/loader.py +0 -0
  94. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/migration.py +0 -0
  95. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/operations/__init__.py +0 -0
  96. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/operations/base.py +0 -0
  97. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/operations/fields.py +0 -0
  98. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/operations/models.py +0 -0
  99. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/operations/special.py +0 -0
  100. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/optimizer.py +0 -0
  101. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/questioner.py +0 -0
  102. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/recorder.py +0 -0
  103. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/serializer.py +0 -0
  104. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/state.py +0 -0
  105. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/utils.py +0 -0
  106. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/migrations/writer.py +0 -0
  107. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/options.py +0 -0
  108. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/otel.py +0 -0
  109. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/preflight.py +0 -0
  110. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/query.py +0 -0
  111. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/query_utils.py +0 -0
  112. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/registry.py +0 -0
  113. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/schema.py +0 -0
  114. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sources.py +0 -0
  115. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sql/__init__.py +0 -0
  116. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sql/compiler.py +0 -0
  117. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sql/constants.py +0 -0
  118. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sql/datastructures.py +0 -0
  119. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sql/query.py +0 -0
  120. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/sql/where.py +0 -0
  121. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/test/__init__.py +0 -0
  122. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/test/database.py +0 -0
  123. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/test/pytest.py +0 -0
  124. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/transaction.py +0 -0
  125. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/types.py +0 -0
  126. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/types.pyi +0 -0
  127. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/plain/postgres/utils.py +0 -0
  128. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/forms.py +0 -0
  129. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0001_initial.py +0 -0
  130. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  131. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  132. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  133. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  134. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  135. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  136. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  137. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  138. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  139. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  140. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  141. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  142. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  143. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  144. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  145. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  146. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/migrations/__init__.py +0 -0
  147. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/__init__.py +0 -0
  148. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/constraints.py +0 -0
  149. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/defaults.py +0 -0
  150. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/delete.py +0 -0
  151. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/encrypted.py +0 -0
  152. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/forms.py +0 -0
  153. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/indexes.py +0 -0
  154. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/iteration.py +0 -0
  155. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/mixins.py +0 -0
  156. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/nullability.py +0 -0
  157. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/querysets.py +0 -0
  158. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/relationships.py +0 -0
  159. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/trees.py +0 -0
  160. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/models/unregistered.py +0 -0
  161. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/urls.py +0 -0
  162. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/examples/views.py +0 -0
  163. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/settings.py +0 -0
  164. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/app/urls.py +0 -0
  165. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/conftest.py +0 -0
  166. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/conftest_convergence.py +0 -0
  167. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_autodetector_not_null_errors.py +0 -0
  168. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_autodetector_type_change.py +0 -0
  169. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_connection_isolation.py +0 -0
  170. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_connection_lifecycle.py +0 -0
  171. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_connection_pool.py +0 -0
  172. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence.py +0 -0
  173. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence_constraints.py +0 -0
  174. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence_defaults.py +0 -0
  175. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence_fk.py +0 -0
  176. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence_indexes.py +0 -0
  177. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence_nullability.py +0 -0
  178. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_convergence_timeouts.py +0 -0
  179. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_database_url.py +0 -0
  180. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_db_expression_defaults.py +0 -0
  181. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_delete_behaviors.py +0 -0
  182. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_encrypted_fields.py +0 -0
  183. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_exceptions.py +0 -0
  184. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_executor_connection_hook.py +0 -0
  185. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_field_defaults.py +0 -0
  186. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_functions_uuid.py +0 -0
  187. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_health.py +0 -0
  188. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_introspection.py +0 -0
  189. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_iterator.py +0 -0
  190. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_literal_default_persistence.py +0 -0
  191. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_m2m.py +0 -0
  192. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_management_connection.py +0 -0
  193. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_manager_assignment.py +0 -0
  194. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_migration_executor.py +0 -0
  195. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_mixins.py +0 -0
  196. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_modelform_roundtrip.py +0 -0
  197. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_no_callable_defaults.py +0 -0
  198. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_otel_metrics.py +0 -0
  199. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_random_string_field.py +0 -0
  200. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_raw_query.py +0 -0
  201. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_read_only_transactions.py +0 -0
  202. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_related.py +0 -0
  203. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_schema_normalize_type.py +0 -0
  204. {plain_postgres-0.99.0 → plain_postgres-0.99.1}/tests/test_schema_timeouts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.99.0
3
+ Version: 0.99.1
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,15 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.99.1](https://github.com/dropseed/plain/releases/plain-postgres@0.99.1) (2026-04-26)
4
+
5
+ ### What's changed
6
+
7
+ - **Duplicate-index check now catches expression-prefix duplicates.** Previously the check excluded any index containing expressions (it compared raw `indkey`/`indclass` arrays), so a redundant `(LOWER(email))` alongside `(LOWER(email), team_id)` was missed. The query now compares per-column `pg_get_indexdef(indexrelid, k, false)` text — canonical output that includes column name/expression, opclass, collation, and sort order — and checks `pg_am.amname` separately so a hash and btree on the same column don't false-match. ([4bd8a713649f](https://github.com/dropseed/plain/commit/4bd8a713649f))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required.
12
+
3
13
  ## [0.99.0](https://github.com/dropseed/plain/releases/plain-postgres@0.99.0) (2026-04-23)
4
14
 
5
15
  ### What's changed
@@ -66,50 +66,65 @@ 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 where one is a column-prefix of another on the same table.
70
+
71
+ Each index column's canonical definition comes from
72
+ ``pg_get_indexdef(indexrelid, k, false)`` — that text includes the
73
+ column name or expression, plus any non-default operator class,
74
+ collation, or sort order. Comparing per-column definitions means we
75
+ catch expression duplicates (e.g. two ``LOWER(email)`` columns) and
76
+ won't false-positive across different opclasses or collations.
77
+ Access method (``pg_am.amname``) is checked separately because the
78
+ per-column text doesn't include it — a hash and btree on the same
79
+ column have identical column text but support different operators.
80
+ """
70
81
  cursor.execute("""
71
82
  SELECT
72
83
  ct.relname AS table_name,
73
84
  ci.relname AS index_name,
74
- i.indkey::int[] AS column_numbers,
75
- i.indclass::int[] AS opclass_numbers,
85
+ am.amname AS access_method,
86
+ array(
87
+ SELECT pg_get_indexdef(i.indexrelid, k, false)
88
+ FROM generate_series(1, i.indnatts) AS k
89
+ ) AS column_defs,
76
90
  i.indisunique,
77
- pg_size_pretty(pg_relation_size(ci.oid)) AS index_size,
78
- pg_relation_size(ci.oid) AS index_size_bytes
91
+ pg_size_pretty(pg_relation_size(ci.oid)) AS index_size
79
92
  FROM pg_catalog.pg_index i
80
93
  JOIN pg_catalog.pg_class ci ON ci.oid = i.indexrelid
81
94
  JOIN pg_catalog.pg_class ct ON ct.oid = i.indrelid
95
+ JOIN pg_catalog.pg_am am ON am.oid = ci.relam
82
96
  JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace
83
97
  WHERE n.nspname = 'public'
84
98
  AND i.indisvalid
85
- AND i.indexprs IS NULL
86
99
  AND i.indpred IS NULL
87
100
  ORDER BY ct.relname, ci.relname
88
101
  """)
89
102
  rows = cursor.fetchall()
90
103
 
91
- # Group by table
92
- by_table: dict[str, list[tuple[str, list[int], list[int], bool, str, int]]] = {}
93
- for table_name, index_name, cols, opclasses, is_unique, size, size_bytes in rows:
104
+ by_table: dict[str, list[tuple[str, str, list[str], bool, str]]] = {}
105
+ for table_name, index_name, am_name, defs, is_unique, size in rows:
94
106
  by_table.setdefault(table_name, []).append(
95
- (index_name, cols, opclasses, is_unique, size, size_bytes)
107
+ (index_name, am_name, defs, is_unique, size)
96
108
  )
97
109
 
98
110
  items: list[CheckItem] = []
99
- flagged: set[str] = set() # avoid reporting the same index multiple times
111
+ flagged: set[str] = set()
100
112
  for table_name, indexes in by_table.items():
101
113
  for i, idx_a in enumerate(indexes):
102
114
  for idx_b in indexes[i + 1 :]:
103
115
  # Check both directions: is either a prefix of the other?
104
116
  for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
105
- name_s, cols_s, ops_s, unique_s, size_s, _ = shorter
106
- name_l, cols_l, ops_l, _, _, _ = longer
117
+ name_s, am_s, defs_s, unique_s, size_s = shorter
118
+ name_l, am_l, defs_l, _, _ = longer
119
+ # Different access methods serve different operators
120
+ # (e.g. hash supports `=` only, btree supports ordering),
121
+ # and unique indexes serve a constraint purpose.
107
122
  if (
108
123
  name_s not in flagged
109
- and len(cols_s) < len(cols_l)
110
- and cols_l[: len(cols_s)] == cols_s
111
- and ops_l[: len(cols_s)] == ops_s
112
- and not unique_s # unique indexes serve a constraint purpose
124
+ and am_s == am_l
125
+ and not unique_s
126
+ and len(defs_s) < len(defs_l)
127
+ and defs_l[: len(defs_s)] == defs_s
113
128
  ):
114
129
  source, package, model_class, model_file = _table_info(
115
130
  table_name, table_owners
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.99.0"
3
+ version = "0.99.1"
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"
@@ -126,6 +126,174 @@ class TestStructuralScenarios:
126
126
  assert flagged[0]["name"] == "_diag_dup_a_idx"
127
127
  assert "_diag_dup_ab_idx" in flagged[0]["detail"]
128
128
 
129
+ def test_duplicate_indexes_detected_against_expression_index(self) -> None:
130
+ """A plain-column index is redundant with a longer index whose
131
+ trailing columns are expressions (e.g. `(team, LOWER(email))`)."""
132
+ _execute(
133
+ 'CREATE TABLE "_diag_dup_expr" ('
134
+ '"id" serial PRIMARY KEY, "team_id" int, "email" text)'
135
+ )
136
+ _execute(
137
+ 'CREATE INDEX "_diag_dup_expr_team_idx" ON "_diag_dup_expr" ("team_id")'
138
+ )
139
+ _execute(
140
+ 'CREATE UNIQUE INDEX "_diag_dup_expr_team_lower_email_uniq" '
141
+ 'ON "_diag_dup_expr" ("team_id", LOWER("email"))'
142
+ )
143
+
144
+ conn = get_connection()
145
+ with conn.cursor() as cursor:
146
+ result = check_duplicate_indexes(cursor, {})
147
+
148
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_expr"]
149
+ assert len(flagged) == 1, (
150
+ f"expected one duplicate on _diag_dup_expr, got {flagged}"
151
+ )
152
+ assert flagged[0]["name"] == "_diag_dup_expr_team_idx"
153
+ assert "_diag_dup_expr_team_lower_email_uniq" in flagged[0]["detail"]
154
+
155
+ def test_duplicate_indexes_detected_when_shorter_is_expression(self) -> None:
156
+ """A `(LOWER(email))` index is redundant with `(LOWER(email), team_id)`
157
+ — the longer index's leading column satisfies any read on the shorter,
158
+ and `pg_get_indexdef` per-column comparison catches the match."""
159
+ _execute(
160
+ 'CREATE TABLE "_diag_dup_expr_short" ('
161
+ '"id" serial PRIMARY KEY, "email" text, "team_id" int)'
162
+ )
163
+ _execute(
164
+ 'CREATE INDEX "_diag_dup_expr_short_lower_idx" '
165
+ 'ON "_diag_dup_expr_short" (LOWER("email"))'
166
+ )
167
+ _execute(
168
+ 'CREATE INDEX "_diag_dup_expr_short_lower_team_idx" '
169
+ 'ON "_diag_dup_expr_short" (LOWER("email"), "team_id")'
170
+ )
171
+
172
+ conn = get_connection()
173
+ with conn.cursor() as cursor:
174
+ result = check_duplicate_indexes(cursor, {})
175
+
176
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_expr_short"]
177
+ assert len(flagged) == 1, (
178
+ f"expected one duplicate on _diag_dup_expr_short, got {flagged}"
179
+ )
180
+ assert flagged[0]["name"] == "_diag_dup_expr_short_lower_idx"
181
+ assert "_diag_dup_expr_short_lower_team_idx" in flagged[0]["detail"]
182
+
183
+ def test_duplicate_indexes_not_flagged_for_same_length_expression_indexes(
184
+ self,
185
+ ) -> None:
186
+ """Two single-column expression indexes with different expressions
187
+ (`LOWER(email)` vs `UPPER(email)`) — neither qualifies as shorter
188
+ under `len(defs_s) < len(defs_l)`, so no false positive."""
189
+ _execute(
190
+ 'CREATE TABLE "_diag_dup_expr_eq" ("id" serial PRIMARY KEY, "email" text)'
191
+ )
192
+ _execute(
193
+ 'CREATE INDEX "_diag_dup_expr_eq_lower_idx" '
194
+ 'ON "_diag_dup_expr_eq" (LOWER("email"))'
195
+ )
196
+ _execute(
197
+ 'CREATE INDEX "_diag_dup_expr_eq_upper_idx" '
198
+ 'ON "_diag_dup_expr_eq" (UPPER("email"))'
199
+ )
200
+
201
+ conn = get_connection()
202
+ with conn.cursor() as cursor:
203
+ result = check_duplicate_indexes(cursor, {})
204
+
205
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_expr_eq"]
206
+ assert flagged == [], (
207
+ f"expected no duplicates flagged for same-length expression indexes, got {flagged}"
208
+ )
209
+
210
+ def test_duplicate_indexes_detected_when_longer_expression_index_is_not_unique(
211
+ self,
212
+ ) -> None:
213
+ """Uniqueness only matters for the shorter side (a unique short index
214
+ serves a constraint purpose). The longer side's uniqueness is
215
+ irrelevant — a redundant non-unique short index should still be
216
+ flagged against a non-unique longer expression index."""
217
+ _execute(
218
+ 'CREATE TABLE "_diag_dup_expr_nonuniq" ('
219
+ '"id" serial PRIMARY KEY, "team_id" int, "email" text)'
220
+ )
221
+ _execute(
222
+ 'CREATE INDEX "_diag_dup_expr_nonuniq_team_idx" '
223
+ 'ON "_diag_dup_expr_nonuniq" ("team_id")'
224
+ )
225
+ _execute(
226
+ 'CREATE INDEX "_diag_dup_expr_nonuniq_team_lower_idx" '
227
+ 'ON "_diag_dup_expr_nonuniq" ("team_id", LOWER("email"))'
228
+ )
229
+
230
+ conn = get_connection()
231
+ with conn.cursor() as cursor:
232
+ result = check_duplicate_indexes(cursor, {})
233
+
234
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_expr_nonuniq"]
235
+ assert len(flagged) == 1, (
236
+ f"expected one duplicate on _diag_dup_expr_nonuniq, got {flagged}"
237
+ )
238
+ assert flagged[0]["name"] == "_diag_dup_expr_nonuniq_team_idx"
239
+ assert "_diag_dup_expr_nonuniq_team_lower_idx" in flagged[0]["detail"]
240
+
241
+ def test_duplicate_indexes_not_flagged_across_access_methods(self) -> None:
242
+ """A hash index and a btree index on the same column support different
243
+ operators (hash: only `=`; btree: ordering, range, etc.). Per-column
244
+ text is identical, so we must check `pg_am.amname` to avoid telling
245
+ the user to drop a deliberately-chosen hash index."""
246
+ _execute(
247
+ 'CREATE TABLE "_diag_dup_am" ('
248
+ '"id" serial PRIMARY KEY, "team_id" int, "email" text)'
249
+ )
250
+ _execute(
251
+ 'CREATE INDEX "_diag_dup_am_team_hash_idx" '
252
+ 'ON "_diag_dup_am" USING hash ("team_id")'
253
+ )
254
+ _execute(
255
+ 'CREATE INDEX "_diag_dup_am_team_email_idx" '
256
+ 'ON "_diag_dup_am" USING btree ("team_id", LOWER("email"))'
257
+ )
258
+
259
+ conn = get_connection()
260
+ with conn.cursor() as cursor:
261
+ result = check_duplicate_indexes(cursor, {})
262
+
263
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_am"]
264
+ assert flagged == [], (
265
+ f"expected no duplicates flagged across access methods, got {flagged}"
266
+ )
267
+
268
+ def test_duplicate_indexes_not_flagged_when_longer_starts_with_expression(
269
+ self,
270
+ ) -> None:
271
+ """A column-only shorter index `(team_id)` is not a true prefix of a
272
+ longer index that leads with an expression `(LOWER(email), team_id)`
273
+ — Postgres can't satisfy `WHERE team_id = ?` from the longer index,
274
+ and per-column text comparison correctly skips it."""
275
+ _execute(
276
+ 'CREATE TABLE "_diag_dup_expr_lead" ('
277
+ '"id" serial PRIMARY KEY, "email" text, "team_id" int)'
278
+ )
279
+ _execute(
280
+ 'CREATE INDEX "_diag_dup_expr_lead_team_idx" '
281
+ 'ON "_diag_dup_expr_lead" ("team_id")'
282
+ )
283
+ _execute(
284
+ 'CREATE INDEX "_diag_dup_expr_lead_lower_team_idx" '
285
+ 'ON "_diag_dup_expr_lead" (LOWER("email"), "team_id")'
286
+ )
287
+
288
+ conn = get_connection()
289
+ with conn.cursor() as cursor:
290
+ result = check_duplicate_indexes(cursor, {})
291
+
292
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_expr_lead"]
293
+ assert flagged == [], (
294
+ f"expected no duplicates flagged when longer leads with expression, got {flagged}"
295
+ )
296
+
129
297
  def test_missing_fk_index_detected(self) -> None:
130
298
  _execute('CREATE TABLE "_diag_fk_parent" ("id" serial PRIMARY KEY)')
131
299
  _execute(
File without changes