plain.postgres 0.109.0__tar.gz → 0.110.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 (222) hide show
  1. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/PKG-INFO +1 -1
  2. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/CHANGELOG.md +14 -3
  3. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/base.py +5 -8
  4. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/related.py +29 -76
  5. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/related_descriptors.py +3 -31
  6. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/related_lookups.py +24 -18
  7. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/related_managers.py +32 -43
  8. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/reverse_related.py +6 -56
  9. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/forms.py +1 -1
  10. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/lookups.py +14 -9
  11. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/meta.py +1 -2
  12. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/autodetector.py +4 -11
  13. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/query_utils.py +1 -1
  14. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sql/compiler.py +1 -1
  15. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sql/datastructures.py +8 -8
  16. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sql/query.py +10 -13
  17. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/pyproject.toml +1 -1
  18. plain_postgres-0.110.0/tests/public/test_related_instance_filter.py +89 -0
  19. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/.gitignore +0 -0
  20. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/CLAUDE.md +0 -0
  21. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/LICENSE +0 -0
  22. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/README.md +0 -0
  23. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/README.md +0 -0
  24. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/__init__.py +0 -0
  25. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/adapters.py +0 -0
  26. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  27. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  28. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/aggregates.py +0 -0
  29. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/__init__.py +0 -0
  30. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/converge.py +0 -0
  31. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/core.py +0 -0
  32. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/decorators.py +0 -0
  33. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/diagnose.py +0 -0
  34. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/migrations.py +0 -0
  35. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/schema.py +0 -0
  36. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/cli/sync.py +0 -0
  37. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/config.py +0 -0
  38. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/connection.py +0 -0
  39. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/constants.py +0 -0
  40. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/constraints.py +0 -0
  41. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/convergence/__init__.py +0 -0
  42. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/convergence/analysis.py +0 -0
  43. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/convergence/fixes.py +0 -0
  44. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/convergence/planning.py +0 -0
  45. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/database_url.py +0 -0
  46. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/db.py +0 -0
  47. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/ddl.py +0 -0
  48. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/default_settings.py +0 -0
  49. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/deletion.py +0 -0
  50. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/dialect.py +0 -0
  51. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/entrypoints.py +0 -0
  52. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/enums.py +0 -0
  53. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/exceptions.py +0 -0
  54. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/expressions.py +0 -0
  55. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/__init__.py +0 -0
  56. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/base.py +0 -0
  57. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/binary.py +0 -0
  58. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/boolean.py +0 -0
  59. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/duration.py +0 -0
  60. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/encrypted.py +0 -0
  61. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/json.py +0 -0
  62. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/mixins.py +0 -0
  63. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/network.py +0 -0
  64. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/numeric.py +0 -0
  65. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/primary_key.py +0 -0
  66. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  67. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/temporal.py +0 -0
  68. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/text.py +0 -0
  69. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/timezones.py +0 -0
  70. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/fields/uuid.py +0 -0
  71. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/__init__.py +0 -0
  72. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/comparison.py +0 -0
  73. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/datetime.py +0 -0
  74. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/math.py +0 -0
  75. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/mixins.py +0 -0
  76. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/random.py +0 -0
  77. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/text.py +0 -0
  78. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/uuid.py +0 -0
  79. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/functions/window.py +0 -0
  80. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/indexes.py +0 -0
  81. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/__init__.py +0 -0
  82. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/__init__.py +0 -0
  83. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  84. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  85. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  86. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/context.py +0 -0
  87. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/helpers.py +0 -0
  88. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/ownership.py +0 -0
  89. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/runner.py +0 -0
  90. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/health/types.py +0 -0
  91. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/introspection/schema.py +0 -0
  92. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/middleware.py +0 -0
  93. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/__init__.py +0 -0
  94. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/exceptions.py +0 -0
  95. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/executor.py +0 -0
  96. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/graph.py +0 -0
  97. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/loader.py +0 -0
  98. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/migration.py +0 -0
  99. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  100. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/operations/base.py +0 -0
  101. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/operations/fields.py +0 -0
  102. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/operations/models.py +0 -0
  103. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/operations/special.py +0 -0
  104. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/optimizer.py +0 -0
  105. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/questioner.py +0 -0
  106. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/recorder.py +0 -0
  107. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/serializer.py +0 -0
  108. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/state.py +0 -0
  109. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/utils.py +0 -0
  110. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/migrations/writer.py +0 -0
  111. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/options.py +0 -0
  112. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/otel.py +0 -0
  113. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/preflight/__init__.py +0 -0
  114. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/preflight/database.py +0 -0
  115. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/preflight/indexes.py +0 -0
  116. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/preflight/models.py +0 -0
  117. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/query.py +0 -0
  118. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/registry.py +0 -0
  119. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/schema.py +0 -0
  120. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sources.py +0 -0
  121. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sql/__init__.py +0 -0
  122. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sql/constants.py +0 -0
  123. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/sql/where.py +0 -0
  124. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/test/__init__.py +0 -0
  125. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/test/database.py +0 -0
  126. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/test/pytest.py +0 -0
  127. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/transaction.py +0 -0
  128. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/types.py +0 -0
  129. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/types.pyi +0 -0
  130. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/plain/postgres/utils.py +0 -0
  131. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/forms.py +0 -0
  132. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  133. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  134. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  135. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  136. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  137. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  138. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  139. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  140. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  141. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  142. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  143. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  144. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  145. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  146. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  147. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  148. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  149. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  150. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/migrations/__init__.py +0 -0
  151. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/__init__.py +0 -0
  152. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/constraints.py +0 -0
  153. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/defaults.py +0 -0
  154. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/delete.py +0 -0
  155. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/encrypted.py +0 -0
  156. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/forms.py +0 -0
  157. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/indexes.py +0 -0
  158. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/iteration.py +0 -0
  159. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/mixins.py +0 -0
  160. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/nullability.py +0 -0
  161. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/querysets.py +0 -0
  162. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/relationships.py +0 -0
  163. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/storage_parameters.py +0 -0
  164. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/trees.py +0 -0
  165. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/models/unregistered.py +0 -0
  166. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/urls.py +0 -0
  167. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/examples/views.py +0 -0
  168. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/settings.py +0 -0
  169. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/app/urls.py +0 -0
  170. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/conftest.py +0 -0
  171. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/conftest_convergence.py +0 -0
  172. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  173. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_autodetector_type_change.py +0 -0
  174. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_connection_isolation.py +0 -0
  175. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_connection_lifecycle.py +0 -0
  176. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_connection_pool.py +0 -0
  177. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_connection_self_heal.py +0 -0
  178. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_constraint_violation_error.py +0 -0
  179. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence.py +0 -0
  180. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_constraints.py +0 -0
  181. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_defaults.py +0 -0
  182. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_fk.py +0 -0
  183. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_indexes.py +0 -0
  184. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_nullability.py +0 -0
  185. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  186. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_convergence_timeouts.py +0 -0
  187. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_db_expression_defaults.py +0 -0
  188. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_diagnose.py +0 -0
  189. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_executor_connection_hook.py +0 -0
  190. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_fk_characterization.py +0 -0
  191. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_health.py +0 -0
  192. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_introspection.py +0 -0
  193. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_literal_default_persistence.py +0 -0
  194. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_management_connection.py +0 -0
  195. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_migration_executor.py +0 -0
  196. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_no_callable_defaults.py +0 -0
  197. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_otel_metrics.py +0 -0
  198. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  199. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  200. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  201. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_schema_normalize_type.py +0 -0
  202. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/internal/test_schema_timeouts.py +0 -0
  203. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_create_update.py +0 -0
  204. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_database_url.py +0 -0
  205. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_deferred_loading.py +0 -0
  206. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_delete_behaviors.py +0 -0
  207. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_encrypted_fields.py +0 -0
  208. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_exceptions.py +0 -0
  209. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_field_defaults.py +0 -0
  210. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_functions_uuid.py +0 -0
  211. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_integrity_error_mapping.py +0 -0
  212. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_iterator.py +0 -0
  213. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_m2m.py +0 -0
  214. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_manager_assignment.py +0 -0
  215. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_manual_pk.py +0 -0
  216. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_mixins.py +0 -0
  217. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_modelform_roundtrip.py +0 -0
  218. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_queryset_repr.py +0 -0
  219. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_random_string_field.py +0 -0
  220. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_raw_query.py +0 -0
  221. {plain_postgres-0.109.0 → plain_postgres-0.110.0}/tests/public/test_read_only_transactions.py +0 -0
  222. {plain_postgres-0.109.0 → plain_postgres-0.110.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.109.0
3
+ Version: 0.110.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,16 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.110.0](https://github.com/dropseed/plain/releases/plain-postgres@0.110.0) (2026-06-17)
4
+
5
+ ### What's changed
6
+
7
+ - Internal: the foreign-key/related-field internals are collapsed to the single-column-to-`id` model Plain actually uses, removing the inherited Django composite-key and `to_field` scaffolding. `PathInfo.target_fields` (a tuple) becomes a scalar `target_field`, and a `Join` now holds one `join_col` pair instead of a `join_cols` list. The unused multi-column helpers go with it: `RelatedField.resolve_related_fields()`, the `related_fields`/`local_related_fields`/`foreign_related_fields` properties, `ForeignKeyField.get_reverse_joining_columns()`, and `ForeignObjectRel.get_related_field()` / `field_name` / `set_field_name()` / `db_type` / the `multiple` flag (along with its now-dead reverse-cache priming, left over from the long-removed one-to-one relation). `get_local_related_value()`, `get_foreign_related_value()`, and the join-column accessors now return scalars instead of 1-tuples. ([cbd15fb707](https://github.com/dropseed/plain/commit/cbd15fb707))
8
+ - Internal: the related-lookup prep path (`related_lookups.py`, `lookups.py`) now narrows on `RelatedField | ForeignObjectRel` to find a relation's target field instead of duck-typing with `getattr(output_field, "target_field")` / `hasattr(..., "path_infos")`, so the dependency is greppable, type-checked, and fails loudly if it's ever removed rather than silently falling back. ([cbd15fb707](https://github.com/dropseed/plain/commit/cbd15fb707))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required. These are internal ORM query-construction details — how foreign keys, reverse relations, and many-to-many relations behave (filtering, prefetching, related-manager access) is unchanged.
13
+
3
14
  ## [0.109.0](https://github.com/dropseed/plain/releases/plain-postgres@0.109.0) (2026-06-17)
4
15
 
5
16
  ### What's changed
@@ -14,7 +25,7 @@
14
25
  - Remove any `db_constraint=False` arguments from `ForeignKeyField`. Foreign keys are always DB-constrained now; an unconstrained FK (e.g. for soft/cross-system references) is no longer supported.
15
26
  - Remove `limit_choices_to=` from `ForeignKeyField` / `ManyToManyField` definitions. To constrain the rows offered for a relation, filter the related queryset explicitly in your form or view.
16
27
  - Replace any `QuerySet.complex_filter(...)` calls with `.filter(...)` — pass a dict of lookups or a `Q` object directly.
17
- - Most projects use none of these and need no changes. The `/plain-upgrade` skill handles the rewrites.
28
+ - Most projects use none of these and need no changes.
18
29
 
19
30
  ## [0.108.1](https://github.com/dropseed/plain/releases/plain-postgres@0.108.1) (2026-06-09)
20
31
 
@@ -57,7 +68,7 @@
57
68
 
58
69
  ### Upgrade instructions
59
70
 
60
- - Replace `form.save()` with `form.create()` in create flows and `form.update()` in update flows. The `/plain-upgrade` skill rewrites these for you — the create-vs-update choice is context-dependent, so it reasons per call site.
71
+ - Replace `form.save()` with `form.create()` in create flows and `form.update()` in update flows.
61
72
  - `form.update(fields=[...])` is available to limit the columns written (a pass-through to `Model.update(fields=...)`).
62
73
  - For the old `form.save(commit=False)` pattern (set extra non-form attributes, then save), assign those attributes on `form.instance` after `is_valid()`, then call `form.create()` / `form.update()` — m2m data is still saved for you, so the separate `save_m2m()` step is no longer needed:
63
74
 
@@ -79,7 +90,7 @@
79
90
 
80
91
  ### Upgrade instructions
81
92
 
82
- - Replace `obj.save()` with `obj.create()` for a new row or `obj.update()` for an existing one; `obj.save(update_fields=[...])` becomes `obj.update(fields=[...])`. The `/plain-upgrade` skill rewrites these for you — the create-vs-update choice is context-dependent, so it reasons per call site.
93
+ - Replace `obj.save()` with `obj.create()` for a new row or `obj.update()` for an existing one; `obj.save(update_fields=[...])` becomes `obj.update(fields=[...])`.
83
94
  - Remove `force_insert=`/`force_update=` — `create()` and `update()` are the explicit forms.
84
95
  - Don't pass `id=` to a model constructor; load existing rows with `Model.query.get(id=...)`.
85
96
  - Catch `ValidationError` (or `(psycopg.IntegrityError, ValidationError)` when retrying) for unique/check conflicts on instance writes. Inside an open `transaction.atomic()` a violation aborts the transaction — wrap the write in its own `atomic()` to catch and continue.
@@ -538,9 +538,6 @@ class Model(metaclass=ModelBase):
538
538
  # constraints aren't supported by the database, there's the
539
539
  # unavoidable risk of data corruption.
540
540
  if obj.id is None:
541
- # Remove the object from a related instance cache.
542
- if not field.remote_field.multiple:
543
- field.remote_field.delete_cached_value(obj)
544
541
  raise ValueError(
545
542
  f"{operation_name}() prohibited to prevent data loss due to unsaved "
546
543
  f"related object '{field.name}'."
@@ -549,10 +546,10 @@ class Model(metaclass=ModelBase):
549
546
  # Set related object if it has been saved after an
550
547
  # assignment.
551
548
  setattr(self, field.name, obj)
552
- # If the relationship's pk/to_field was changed, clear the
553
- # cached relationship. Compare the cached object's key against
554
- # the raw key value -- not getattr(self, field.name), which
555
- # for a foreign key returns the related object, not the key.
549
+ # If the relationship's key was changed, clear the cached
550
+ # relationship. Compare the cached object's key against the raw
551
+ # key value -- not getattr(self, field.name), which for a
552
+ # foreign key returns the related object, not the key.
556
553
  if getattr(obj, field.target_field.name) != field.value_from_object(
557
554
  self
558
555
  ):
@@ -622,7 +619,7 @@ class Model(metaclass=ModelBase):
622
619
  raise ValueError(
623
620
  f"Unsaved model instance {self!r} cannot be used in an ORM query."
624
621
  )
625
- return getattr(self, field.remote_field.get_related_field().name)
622
+ return getattr(self, field.target_field.name)
626
623
 
627
624
  def clean(self) -> None:
628
625
  """
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Self, cast
7
7
 
8
8
  from plain.postgres.constants import LOOKUP_SEP
9
9
  from plain.postgres.deletion import SET_NULL, OnDelete
10
- from plain.postgres.exceptions import FieldDoesNotExist, FieldError
10
+ from plain.postgres.exceptions import FieldDoesNotExist
11
11
  from plain.postgres.query_utils import PathInfo
12
12
  from plain.postgres.utils import make_model_tuple
13
13
  from plain.preflight import PreflightResult
@@ -277,7 +277,6 @@ class RelatedField(FieldCacheMixin, Field):
277
277
  self.name = self.name or (
278
278
  self.remote_field.model.model_options.model_name + "_" + "id"
279
279
  )
280
- self.remote_field.set_field_name()
281
280
 
282
281
  def do_related_class(self, other: type[Model], cls: type[Model]) -> None:
283
282
  self.set_attributes_from_rel()
@@ -294,16 +293,12 @@ class RelatedField(FieldCacheMixin, Field):
294
293
  @property
295
294
  def target_field(self) -> Field:
296
295
  """
297
- When filtering against this relation, return the field on the remote
298
- model against which the filtering should happen.
296
+ The remote field this relation filters against. Read dynamically by the
297
+ related-lookup prep path (`output_field.target_field`). ForeignKeyField
298
+ overrides this with a direct, cached lookup; ManyToManyField uses this
299
+ path-based form.
299
300
  """
300
- target_fields = self.path_infos[-1].target_fields
301
- if len(target_fields) > 1:
302
- raise FieldError(
303
- "The relation has multiple target fields, but only single target field "
304
- "was asked for"
305
- )
306
- return target_fields[0]
301
+ return self.path_infos[-1].target_field
307
302
 
308
303
  def get_cache_name(self) -> str:
309
304
  assert self.name is not None, "Field name must be set"
@@ -372,49 +367,28 @@ class ForeignKeyField(ColumnField, RelatedField):
372
367
 
373
368
  def __copy__(self) -> ForeignKeyField:
374
369
  obj = super().__copy__()
375
- # Remove any cached PathInfo values.
370
+ # Remove cached values that depend on the (possibly not-yet-resolved)
371
+ # remote model.
372
+ obj.__dict__.pop("target_field", None)
376
373
  obj.__dict__.pop("path_infos", None)
377
374
  obj.__dict__.pop("reverse_path_infos", None)
378
375
  return obj
379
376
 
380
- @cached_property
381
- def related_fields(self) -> list[tuple[ForeignKeyField, Field]]:
382
- return self.resolve_related_fields()
377
+ def get_local_related_value(self, instance: Model) -> Any:
378
+ # The local field is the foreign key itself; read its raw key value, not
379
+ # the related object the descriptor returns.
380
+ return self._get_raw_value(instance)
383
381
 
384
- @cached_property
385
- def local_related_fields(self) -> tuple[Field, ...]:
386
- return tuple(lhs_field for lhs_field, rhs_field in self.related_fields)
382
+ def get_foreign_related_value(self, instance: Model) -> Any:
383
+ # A foreign key always points at the remote instance's id.
384
+ return instance.id
387
385
 
388
- @cached_property
389
- def foreign_related_fields(self) -> tuple[Field, ...]:
390
- return tuple(
391
- rhs_field for lhs_field, rhs_field in self.related_fields if rhs_field
392
- )
393
-
394
- def get_local_related_value(self, instance: Model) -> tuple[Any, ...]:
395
- # The single local field is the foreign key itself.
396
- if self.primary_key:
397
- return (instance.id,)
398
- # Read the raw key value, not the related object the descriptor returns.
399
- return (self._get_raw_value(instance),)
400
-
401
- def get_foreign_related_value(self, instance: Model) -> tuple[Any, ...]:
402
- # Always returns the id of the foreign instance
403
- return (instance.id,)
404
-
405
- def get_joining_columns(
406
- self, reverse_join: bool = False
407
- ) -> tuple[tuple[str, str], ...]:
408
- # Always returns a single column pair
386
+ def get_joining_columns(self, reverse_join: bool = False) -> tuple[str, str]:
387
+ # A foreign key joins one column pair: this field's column and the
388
+ # target's id column.
409
389
  if reverse_join:
410
- from_field, to_field = self.related_fields[0]
411
- return ((to_field.column, from_field.column),)
412
- else:
413
- from_field, to_field = self.related_fields[0]
414
- return ((from_field.column, to_field.column),)
415
-
416
- def get_reverse_joining_columns(self) -> tuple[tuple[str, str], ...]:
417
- return self.get_joining_columns(reverse_join=True)
390
+ return (self.target_field.column, self.column)
391
+ return (self.column, self.target_field.column)
418
392
 
419
393
  def get_path_info(self, filtered_relation: Any = None) -> list[PathInfo]:
420
394
  """Get path from this field to the related model."""
@@ -424,7 +398,7 @@ class ForeignKeyField(ColumnField, RelatedField):
424
398
  PathInfo(
425
399
  from_meta=from_meta,
426
400
  to_meta=meta,
427
- target_fields=self.foreign_related_fields,
401
+ target_field=self.target_field,
428
402
  join_field=self,
429
403
  m2m=False,
430
404
  direct=True,
@@ -444,9 +418,10 @@ class ForeignKeyField(ColumnField, RelatedField):
444
418
  PathInfo(
445
419
  from_meta=from_meta,
446
420
  to_meta=meta,
447
- target_fields=(meta.get_forward_field("id"),),
421
+ target_field=meta.get_forward_field("id"),
448
422
  join_field=self.remote_field,
449
- m2m=not self.primary_key,
423
+ # The reverse of a foreign key always fans out to many rows.
424
+ m2m=True,
450
425
  direct=False,
451
426
  filtered_relation=filtered_relation,
452
427
  )
@@ -500,26 +475,14 @@ class ForeignKeyField(ColumnField, RelatedField):
500
475
  def to_python(self, value: Any) -> Any:
501
476
  return self.target_field.to_python(value)
502
477
 
503
- @property
478
+ @cached_property
504
479
  def target_field(self) -> Field:
505
- return self.foreign_related_fields[0]
506
-
507
- def resolve_related_fields(self) -> list[tuple[ForeignKeyField, Field]]:
480
+ """A foreign key points at exactly one column: the remote model's id."""
508
481
  if isinstance(self.remote_field.model, str):
509
482
  raise ValueError(
510
483
  f"Related model {self.remote_field.model!r} cannot be resolved"
511
484
  )
512
- from_field = self
513
- to_field = self.remote_field.model._model_meta.get_forward_field("id")
514
- related_fields: list[tuple[ForeignKeyField, Field]] = [(from_field, to_field)]
515
-
516
- for from_field, to_field in related_fields:
517
- if to_field and to_field.model != self.remote_field.model:
518
- raise FieldError(
519
- f"'{self.model.model_options.label}.{self.name}' refers to field '{to_field.name}' which is not local to model "
520
- f"'{self.remote_field.model.model_options.label}'."
521
- )
522
- return related_fields
485
+ return self.remote_field.model._model_meta.get_forward_field("id")
523
486
 
524
487
  def set_attributes_from_name(self, name: str) -> None:
525
488
  super().set_attributes_from_name(name)
@@ -574,11 +537,8 @@ class ForeignKeyField(ColumnField, RelatedField):
574
537
 
575
538
  def get_col(self, alias: str | None, output_field: Field | None = None) -> Any:
576
539
  if output_field is None:
540
+ # A foreign key resolves to its target's id column.
577
541
  output_field = self.target_field
578
- while isinstance(output_field, ForeignKeyField):
579
- output_field = output_field.target_field
580
- if output_field is self:
581
- raise ValueError("Cannot resolve output_field.")
582
542
  return super().get_col(alias, output_field)
583
543
 
584
544
 
@@ -1048,13 +1008,6 @@ class ManyToManyField(RelatedField):
1048
1008
  self._get_m2m_reverse_attr, related, "name"
1049
1009
  )
1050
1010
 
1051
- get_m2m_rel = partial(self._get_m2m_attr, related, "remote_field")
1052
- self.m2m_target_field_name = lambda: get_m2m_rel().field_name
1053
- get_m2m_reverse_rel = partial(
1054
- self._get_m2m_reverse_attr, related, "remote_field"
1055
- )
1056
- self.m2m_reverse_target_field_name = lambda: get_m2m_reverse_rel().field_name
1057
-
1058
1011
  def set_attributes_from_rel(self) -> None:
1059
1012
  pass
1060
1013
 
@@ -23,11 +23,6 @@ reverse foreign key relation.
23
23
  1. Related instance on the forward side of a foreign key relation:
24
24
  ``ForwardForeignKeyDescriptor``.
25
25
 
26
- Uniqueness of foreign key values is irrelevant to accessing the related
27
- instance, making the many-to-one and one-to-one cases identical as far as
28
- the descriptor is concerned. The constraint is checked upstream (unicity
29
- validation in forms) or downstream (unique indexes in the database).
30
-
31
26
  2. Related objects manager for related instances on the forward or reverse
32
27
  sides of a many-to-many relation: ``ForwardManyToManyDescriptor``.
33
28
 
@@ -94,22 +89,14 @@ class ForwardForeignKeyDescriptor:
94
89
 
95
90
  rel_obj_attr = self.field.get_foreign_related_value
96
91
  instance_attr = self.field.get_local_related_value
97
- instances_dict = {instance_attr(inst): inst for inst in instances}
98
- related_field = self.field.foreign_related_fields[0]
99
- remote_field = self.field.remote_field
92
+ related_field = self.field.target_field
100
93
 
101
94
  # A foreign key is single-column, so prefetch with a join-less IN query.
102
95
  query = {
103
- f"{related_field.name}__in": {instance_attr(inst)[0] for inst in instances}
96
+ f"{related_field.name}__in": {instance_attr(inst) for inst in instances}
104
97
  }
105
98
  queryset = queryset.filter(**query)
106
99
 
107
- # Since we're going to assign directly in the cache,
108
- # we must manage the reverse relation cache manually.
109
- if not remote_field.multiple:
110
- for rel_obj in queryset:
111
- instance = instances_dict[rel_obj_attr(rel_obj)]
112
- remote_field.set_cached_value(rel_obj, instance)
113
100
  return (
114
101
  queryset,
115
102
  rel_obj_attr,
@@ -152,9 +139,6 @@ class ForwardForeignKeyDescriptor:
152
139
  # no query. Accessing any other field triggers the full-row
153
140
  # deferred load.
154
141
  rel_obj = remote_model.from_db([target_name], [pk_value])
155
- # For a one-to-one relation, prime the reverse cache too.
156
- if not self.field.remote_field.multiple:
157
- self.field.remote_field.set_cached_value(rel_obj, instance)
158
142
  self.field.set_cached_value(instance, rel_obj)
159
143
 
160
144
  # Checked on every access, including a cached None: a non-nullable
@@ -186,11 +170,6 @@ class ForwardForeignKeyDescriptor:
186
170
  remote_field = self.field.remote_field
187
171
 
188
172
  if value is None:
189
- # Clear the reverse cache on any previously-related object so a
190
- # later delete of it does not cascade into this instance.
191
- related = self.field.get_cached_value(instance, default=None)
192
- if related is not None and not remote_field.multiple:
193
- remote_field.set_cached_value(related, None)
194
173
  instance.__dict__[name] = None
195
174
  self.field.set_cached_value(instance, None)
196
175
  return
@@ -199,9 +178,6 @@ class ForwardForeignKeyDescriptor:
199
178
  # A related model instance: store its key, cache the object.
200
179
  instance.__dict__[name] = getattr(value, self.field.target_field.name)
201
180
  self.field.set_cached_value(instance, value)
202
- # For a one-to-one relation, prime the reverse cache.
203
- if not remote_field.multiple:
204
- remote_field.set_cached_value(value, instance)
205
181
  return
206
182
 
207
183
  if isinstance(value, Model | bool):
@@ -217,12 +193,8 @@ class ForwardForeignKeyDescriptor:
217
193
  # A bare related key value (e.g. child.parent = 5).
218
194
  new_value = self.field.to_python(value)
219
195
  if instance.__dict__.get(name) != new_value:
220
- # The key actually changed -- clear the reverse cache on any
221
- # previously-related object and drop the now-stale forward cache.
196
+ # The key actually changed -- drop the now-stale forward cache.
222
197
  # Re-storing the same key (e.g. by clean_fields) keeps the cache.
223
- related = self.field.get_cached_value(instance, default=None)
224
- if related is not None and not remote_field.multiple:
225
- remote_field.set_cached_value(related, None)
226
198
  if self.field.is_cached(instance):
227
199
  self.field.delete_cached_value(instance)
228
200
  instance.__dict__[name] = new_value
@@ -18,36 +18,43 @@ if TYPE_CHECKING:
18
18
  from plain.postgres.sql.compiler import SQLCompiler
19
19
 
20
20
 
21
- def get_normalized_value(value: Any, lhs: Any) -> tuple[Any, ...]:
22
- from plain.postgres import Model
21
+ def _related_target_field(lhs: Any) -> Any:
22
+ """The remote field a relational lhs filters against (e.g. the target's id),
23
+ or None when the lhs is not a relation.
24
+
25
+ Narrowing on the relation types keeps this explicit and type-checked rather
26
+ than probing for a ``path_infos`` attribute with hasattr().
27
+ """
23
28
  from plain.postgres.fields.related import RelatedField
29
+ from plain.postgres.fields.reverse_related import ForeignObjectRel
30
+
31
+ output_field = lhs.output_field
32
+ if isinstance(output_field, RelatedField | ForeignObjectRel):
33
+ return output_field.path_infos[-1].target_field
34
+ return None
35
+
36
+
37
+ def get_normalized_value(value: Any, lhs: Any) -> Any:
38
+ from plain.postgres import Model
24
39
 
25
40
  if isinstance(value, Model):
26
41
  if value.id is None:
27
42
  raise ValueError("Model instances passed to related filters must be saved.")
28
- # FK relations are always single-column (target is remote `id`), so
29
- # path_infos[-1].target_fields is a 1-tuple.
30
- source = lhs.output_field.path_infos[-1].target_fields[0]
31
- while not isinstance(value, source.model) and isinstance(source, RelatedField):
32
- source = source.remote_field.model._model_meta.get_field(
33
- source.remote_field.field_name
34
- )
35
- return (getattr(value, source.name),)
36
- if not isinstance(value, tuple):
37
- return (value,)
43
+ # FK relations are always single-column, targeting the remote `id`.
44
+ source = lhs.output_field.path_infos[-1].target_field
45
+ return getattr(value, source.name)
38
46
  return value
39
47
 
40
48
 
41
49
  class RelatedIn(In):
42
50
  def get_prep_lookup(self) -> list[Any]:
43
51
  if self.rhs_is_direct_value():
44
- self.rhs = [get_normalized_value(val, self.lhs)[0] for val in self.rhs]
52
+ self.rhs = [get_normalized_value(val, self.lhs) for val in self.rhs]
45
53
  # We need to run the related field's get_prep_value(). Consider
46
54
  # case ForeignKeyField to IntegerField given value 'abc'. The
47
55
  # ForeignKeyField itself doesn't have validation for non-integers,
48
56
  # so we must run validation using the target field.
49
- if hasattr(self.lhs.output_field, "path_infos"):
50
- target_field = self.lhs.output_field.path_infos[-1].target_fields[-1]
57
+ if target_field := _related_target_field(self.lhs):
51
58
  self.rhs = [target_field.get_prep_value(v) for v in self.rhs]
52
59
  return super().get_prep_lookup()
53
60
 
@@ -61,13 +68,12 @@ class RelatedLookupMixin(Lookup):
61
68
 
62
69
  def get_prep_lookup(self) -> Any:
63
70
  if not hasattr(self.rhs, "resolve_expression"):
64
- self.rhs = get_normalized_value(self.rhs, self.lhs)[0]
71
+ self.rhs = get_normalized_value(self.rhs, self.lhs)
65
72
  # We need to run the related field's get_prep_value(). Consider case
66
73
  # ForeignKeyField to IntegerField given value 'abc'. The ForeignKeyField itself
67
74
  # doesn't have validation for non-integers, so we must run validation
68
75
  # using the target field.
69
- if self.prepare_rhs and hasattr(self.lhs.output_field, "path_infos"):
70
- target_field = self.lhs.output_field.path_infos[-1].target_fields[-1]
76
+ if self.prepare_rhs and (target_field := _related_target_field(self.lhs)):
71
77
  self.rhs = target_field.get_prep_value(self.rhs)
72
78
 
73
79
  return super().get_prep_lookup()
@@ -97,12 +97,12 @@ class ReverseForeignKeyManager(BaseRelatedManager[T, QS]):
97
97
  self.allow_null = self.field.allow_null
98
98
 
99
99
  def _check_fk_val(self) -> None:
100
- for field in self.field.foreign_related_fields:
101
- if getattr(self.instance, field.name) is None:
102
- raise ValueError(
103
- f'"{self.instance!r}" needs to have a value for field '
104
- f'"{field.name}" before this relationship can be used.'
105
- )
100
+ field = self.field.target_field
101
+ if getattr(self.instance, field.name) is None:
102
+ raise ValueError(
103
+ f'"{self.instance!r}" needs to have a value for field '
104
+ f'"{field.name}" before this relationship can be used.'
105
+ )
106
106
 
107
107
  def _apply_rel_filters(self, queryset: QuerySet) -> QuerySet:
108
108
  """
@@ -110,13 +110,10 @@ class ReverseForeignKeyManager(BaseRelatedManager[T, QS]):
110
110
  """
111
111
  queryset._defer_next_filter = True
112
112
  queryset = queryset.filter(**self.core_filters)
113
- for field in self.field.foreign_related_fields:
114
- val = getattr(self.instance, field.name)
115
- if val is None:
116
- return queryset.none()
117
-
118
113
  target_field = self.field.target_field
119
114
  rel_obj_id = getattr(self.instance, target_field.name)
115
+ if rel_obj_id is None:
116
+ return queryset.none()
120
117
  queryset._known_related_objects = {self.field: {rel_obj_id: self.instance}}
121
118
  return queryset
122
119
 
@@ -129,9 +126,8 @@ class ReverseForeignKeyManager(BaseRelatedManager[T, QS]):
129
126
  pass # nothing to clear from cache
130
127
 
131
128
  def get_queryset(self) -> QS:
132
- # Even if this relation is not to primary key, we require still primary key value.
133
- # The wish is that the instance has been already saved to DB,
134
- # although having a primary key value isn't a guarantee of that.
129
+ # The instance must have a primary key value to be related against,
130
+ # though that isn't a guarantee it has actually been saved to the DB.
135
131
  if self.instance.id is None:
136
132
  raise ValueError(
137
133
  f"{self.instance.__class__.__name__!r} instance needs to have a "
@@ -312,8 +308,7 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
312
308
  target_field_name: str
313
309
  symmetrical: bool
314
310
  core_filters: dict[str, Any]
315
- id_field_names: dict[str, str]
316
- related_val: tuple[Any, ...]
311
+ related_val: Any
317
312
 
318
313
  def __init__(
319
314
  self,
@@ -357,22 +352,20 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
357
352
  self.through._model_meta.get_forward_field(self.target_field_name),
358
353
  )
359
354
 
360
- self.core_filters = {}
361
- self.id_field_names = {}
362
- for lh_field, rh_field in self.source_field.related_fields:
363
- rh_name = rh_field.name
364
- assert rh_name is not None
365
- core_filter_key = f"{self.query_field_name}__{rh_name}"
366
- self.core_filters[core_filter_key] = getattr(instance, rh_name)
367
- self.id_field_names[lh_field.name] = rh_field.name # ty: ignore[invalid-assignment]
368
-
369
- self.related_val = self.source_field.get_foreign_related_value(instance)
370
- if None in self.related_val:
355
+ source_fk = self.source_field
356
+ rh_name = source_fk.target_field.name
357
+ assert rh_name is not None
358
+ self.core_filters = {
359
+ f"{self.query_field_name}__{rh_name}": getattr(instance, rh_name)
360
+ }
361
+
362
+ self.related_val = source_fk.get_foreign_related_value(instance)
363
+ if self.related_val is None:
371
364
  raise ValueError(
372
- f'"{instance!r}" needs to have a value for field "{self.id_field_names[self.source_field_name]}" before '
365
+ f'"{instance!r}" needs to have a value for field "{rh_name}" before '
373
366
  "this many-to-many relationship can be used."
374
367
  )
375
- # Even if this relation is not to primary key, we require still primary key value.
368
+ # The instance must have a primary key value to be related against.
376
369
  if instance.id is None:
377
370
  raise ValueError(
378
371
  f"{instance.__class__.__name__!r} instance needs to have a primary key value before "
@@ -423,20 +416,16 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
423
416
  qn = quote_name
424
417
  queryset = queryset.extra(
425
418
  select={
426
- f"_prefetch_related_val_{f.name}": f"{qn(join_table)}.{qn(f.column)}"
427
- for f in fk.local_related_fields
419
+ f"_prefetch_related_val_{fk.name}": f"{qn(join_table)}.{qn(fk.column)}"
428
420
  }
429
421
  )
430
422
  conn = get_connection()
423
+ target_field = fk.target_field
431
424
  return (
432
425
  queryset,
433
- lambda result: tuple(
434
- getattr(result, f"_prefetch_related_val_{f.name}")
435
- for f in fk.local_related_fields
436
- ),
437
- lambda inst: tuple(
438
- f.get_db_prep_value(f.value_from_object(inst), conn)
439
- for f in fk.foreign_related_fields
426
+ lambda result: getattr(result, f"_prefetch_related_val_{fk.name}"),
427
+ lambda inst: target_field.get_db_prep_value(
428
+ target_field.value_from_object(inst), conn
440
429
  ),
441
430
  False,
442
431
  self.prefetch_cache_name,
@@ -472,7 +461,7 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
472
461
  new_objs = []
473
462
  for obj in objs:
474
463
  fk_val = (
475
- self.target_field.get_foreign_related_value(obj)[0]
464
+ self.target_field.get_foreign_related_value(obj)
476
465
  if isinstance(obj, self.model)
477
466
  else self.target_field.get_prep_value(obj)
478
467
  )
@@ -525,7 +514,7 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
525
514
  ) # M2M through model fields are always ForeignKey
526
515
  for obj in objs:
527
516
  if isinstance(obj, self.model):
528
- target_id = target_field.get_foreign_related_value(obj)[0]
517
+ target_id = target_field.get_foreign_related_value(obj)
529
518
  if target_id is None:
530
519
  raise ValueError(
531
520
  f'Cannot add "{obj!r}": the value for field "{target_field_name}" is None'
@@ -548,7 +537,7 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
548
537
  """Return the subset of ids of `objs` that aren't already assigned to this relationship."""
549
538
  vals = self.through.query.values_list(target_field_name, flat=True).filter(
550
539
  **{
551
- source_field_name: self.related_val[0],
540
+ source_field_name: self.related_val,
552
541
  f"{target_field_name}__in": target_ids,
553
542
  }
554
543
  )
@@ -577,7 +566,7 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
577
566
  self.through(
578
567
  **through_defaults,
579
568
  **{
580
- source_field_name: self.related_val[0],
569
+ source_field_name: self.related_val,
581
570
  target_field_name: target_id,
582
571
  },
583
572
  )
@@ -595,7 +584,7 @@ class ManyToManyManager(BaseRelatedManager[T, QS]):
595
584
  old_ids = set()
596
585
  for obj in objs:
597
586
  if isinstance(obj, self.model):
598
- fk_val = self.target_field.get_foreign_related_value(obj)[0]
587
+ fk_val = self.target_field.get_foreign_related_value(obj)
599
588
  old_ids.add(fk_val)
600
589
  else:
601
590
  old_ids.add(obj)