plain.postgres 0.116.0__tar.gz → 0.117.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 (234) hide show
  1. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/PKG-INFO +1 -1
  2. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/CHANGELOG.md +26 -0
  3. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/base.py +17 -13
  4. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/expressions.py +14 -2
  5. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/base.py +0 -2
  6. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/related.py +48 -45
  7. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/reverse_related.py +34 -11
  8. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/meta.py +6 -7
  9. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/autodetector.py +23 -23
  10. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/graph.py +7 -1
  11. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/state.py +22 -18
  12. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/utils.py +7 -2
  13. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/query.py +8 -13
  14. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/pyproject.toml +1 -1
  15. plain_postgres-0.117.0/tests/internal/test_unresolved_relation_refs.py +144 -0
  16. plain_postgres-0.117.0/tests/public/test_queryset_ordered.py +14 -0
  17. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/.gitignore +0 -0
  18. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/CLAUDE.md +0 -0
  19. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/LICENSE +0 -0
  20. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/README.md +0 -0
  21. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/README.md +0 -0
  22. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/__init__.py +0 -0
  23. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/adapters.py +0 -0
  24. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  25. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  26. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/aggregates.py +0 -0
  27. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/__init__.py +0 -0
  28. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/converge.py +0 -0
  29. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/core.py +0 -0
  30. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/decorators.py +0 -0
  31. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/diagnose.py +0 -0
  32. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/migrations.py +0 -0
  33. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/schema.py +0 -0
  34. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/cli/sync.py +0 -0
  35. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/config.py +0 -0
  36. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/connection.py +0 -0
  37. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/constants.py +0 -0
  38. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/constraints.py +0 -0
  39. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/convergence/__init__.py +0 -0
  40. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/convergence/analysis.py +0 -0
  41. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/convergence/corrections.py +0 -0
  42. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/convergence/planning.py +0 -0
  43. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/database_url.py +0 -0
  44. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/databases.py +0 -0
  45. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/db.py +0 -0
  46. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/ddl.py +0 -0
  47. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/default_settings.py +0 -0
  48. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/deletion.py +0 -0
  49. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/dialect.py +0 -0
  50. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/entrypoints.py +0 -0
  51. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/enums.py +0 -0
  52. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/exceptions.py +0 -0
  53. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/__init__.py +0 -0
  54. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/binary.py +0 -0
  55. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/boolean.py +0 -0
  56. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/duration.py +0 -0
  57. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/encrypted.py +0 -0
  58. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/json.py +0 -0
  59. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/mixins.py +0 -0
  60. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/network.py +0 -0
  61. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/numeric.py +0 -0
  62. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/primary_key.py +0 -0
  63. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/related_descriptors.py +0 -0
  64. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/related_lookups.py +0 -0
  65. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/related_managers.py +0 -0
  66. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  67. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/temporal.py +0 -0
  68. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/text.py +0 -0
  69. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/timezones.py +0 -0
  70. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/fields/uuid.py +0 -0
  71. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/forms.py +0 -0
  72. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/__init__.py +0 -0
  73. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/comparison.py +0 -0
  74. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/datetime.py +0 -0
  75. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/math.py +0 -0
  76. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/mixins.py +0 -0
  77. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/random.py +0 -0
  78. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/text.py +0 -0
  79. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/uuid.py +0 -0
  80. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/functions/window.py +0 -0
  81. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/indexes.py +0 -0
  82. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/__init__.py +0 -0
  83. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/__init__.py +0 -0
  84. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  85. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  86. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  87. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/context.py +0 -0
  88. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/helpers.py +0 -0
  89. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/ownership.py +0 -0
  90. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/runner.py +0 -0
  91. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/health/types.py +0 -0
  92. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/introspection/schema.py +0 -0
  93. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/lookups.py +0 -0
  94. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/middleware.py +0 -0
  95. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/__init__.py +0 -0
  96. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/exceptions.py +0 -0
  97. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/executor.py +0 -0
  98. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/loader.py +0 -0
  99. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/migration.py +0 -0
  100. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  101. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/operations/base.py +0 -0
  102. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/operations/fields.py +0 -0
  103. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/operations/models.py +0 -0
  104. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/operations/special.py +0 -0
  105. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/optimizer.py +0 -0
  106. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/questioner.py +0 -0
  107. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/recorder.py +0 -0
  108. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/serializer.py +0 -0
  109. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/migrations/writer.py +0 -0
  110. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/options.py +0 -0
  111. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/otel.py +0 -0
  112. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/preflight/__init__.py +0 -0
  113. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/preflight/database.py +0 -0
  114. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/preflight/indexes.py +0 -0
  115. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/preflight/models.py +0 -0
  116. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/query_utils.py +0 -0
  117. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/registry.py +0 -0
  118. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/schema.py +0 -0
  119. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/schema_lock.py +0 -0
  120. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sources.py +0 -0
  121. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sql/__init__.py +0 -0
  122. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sql/compiler.py +0 -0
  123. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sql/constants.py +0 -0
  124. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sql/datastructures.py +0 -0
  125. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sql/query.py +0 -0
  126. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/sql/where.py +0 -0
  127. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/test/__init__.py +0 -0
  128. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/test/database.py +0 -0
  129. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/test/pytest.py +0 -0
  130. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/transaction.py +0 -0
  131. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/types.py +0 -0
  132. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/types.pyi +0 -0
  133. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/plain/postgres/utils.py +0 -0
  134. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/forms.py +0 -0
  135. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  136. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  137. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  138. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  139. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  140. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  141. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  142. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  143. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  144. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  145. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  146. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  147. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  148. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  149. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  150. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  151. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  152. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  153. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/migrations/__init__.py +0 -0
  154. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/__init__.py +0 -0
  155. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/constraints.py +0 -0
  156. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/defaults.py +0 -0
  157. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/delete.py +0 -0
  158. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/encrypted.py +0 -0
  159. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/forms.py +0 -0
  160. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/indexes.py +0 -0
  161. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/iteration.py +0 -0
  162. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/mixins.py +0 -0
  163. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/nullability.py +0 -0
  164. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/querysets.py +0 -0
  165. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/relationships.py +0 -0
  166. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/storage_parameters.py +0 -0
  167. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/trees.py +0 -0
  168. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/models/unregistered.py +0 -0
  169. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/urls.py +0 -0
  170. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/examples/views.py +0 -0
  171. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/settings.py +0 -0
  172. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/app/urls.py +0 -0
  173. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/conftest.py +0 -0
  174. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/conftest_convergence.py +0 -0
  175. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_apply_replan.py +0 -0
  176. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  177. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_autodetector_type_change.py +0 -0
  178. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_connection_isolation.py +0 -0
  179. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_connection_lifecycle.py +0 -0
  180. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_connection_pool.py +0 -0
  181. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_connection_self_heal.py +0 -0
  182. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_constraint_violation_error.py +0 -0
  183. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence.py +0 -0
  184. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_constraints.py +0 -0
  185. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_defaults.py +0 -0
  186. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_fk.py +0 -0
  187. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_indexes.py +0 -0
  188. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_nullability.py +0 -0
  189. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  190. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_convergence_timeouts.py +0 -0
  191. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
  192. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_db_expression_defaults.py +0 -0
  193. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_diagnose.py +0 -0
  194. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_executor_connection_hook.py +0 -0
  195. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_fk_characterization.py +0 -0
  196. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_health.py +0 -0
  197. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_introspection.py +0 -0
  198. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_literal_default_persistence.py +0 -0
  199. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_management_connection.py +0 -0
  200. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_migration_executor.py +0 -0
  201. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_no_callable_defaults.py +0 -0
  202. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_otel_metrics.py +0 -0
  203. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  204. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  205. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  206. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
  207. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_schema_lock.py +0 -0
  208. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_schema_normalize_type.py +0 -0
  209. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/internal/test_schema_timeouts.py +0 -0
  210. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_create_update.py +0 -0
  211. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_database_url.py +0 -0
  212. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_databases.py +0 -0
  213. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_deferred_loading.py +0 -0
  214. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_delete_behaviors.py +0 -0
  215. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_encrypted_fields.py +0 -0
  216. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_exceptions.py +0 -0
  217. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_field_defaults.py +0 -0
  218. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_functions_uuid.py +0 -0
  219. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_integrity_error_mapping.py +0 -0
  220. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_iterator.py +0 -0
  221. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_m2m.py +0 -0
  222. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_manager_assignment.py +0 -0
  223. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_manual_pk.py +0 -0
  224. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_mixins.py +0 -0
  225. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_modelform_roundtrip.py +0 -0
  226. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_only_empty_defaults.py +0 -0
  227. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_order_by_expressions.py +0 -0
  228. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_queryset_repr.py +0 -0
  229. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_queryset_slicing.py +0 -0
  230. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_random_string_field.py +0 -0
  231. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_raw_query.py +0 -0
  232. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_read_only_transactions.py +0 -0
  233. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_related.py +0 -0
  234. {plain_postgres-0.116.0 → plain_postgres-0.117.0}/tests/public/test_related_instance_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plain.postgres
3
- Version: 0.116.0
3
+ Version: 0.117.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,31 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.117.0](https://github.com/dropseed/plain/releases/plain-postgres@0.117.0) (2026-09-04)
4
+
5
+ ### What's changed
6
+
7
+ - Fixed `QuerySet.ordered`, which raised `AttributeError: 'Meta' object has no attribute 'ordering'` for any queryset without an explicit `order_by()`. It was reading the model's default ordering off `_model_meta` instead of `model_options`, so the property only ever worked on the one branch that never reached the lookup. It now correctly reports `True` when the model declares `Meta.ordering` and `False` when it doesn't ([d9406dfc6f](https://github.com/dropseed/plain/commit/d9406dfc6f))
8
+ - Unresolved relation references are now separate attributes from resolved model classes. `rel.model` and `ManyToManyRel.through` used to hold whatever you passed to the field — a `"package.Model"` string early on, the class after `lazy_related_operation` overwrote it — so every reader had to guess which it had, and the guessing was done inconsistently (`isinstance(..., str)` in some places, `isinstance(..., ModelBase)` in others, `hasattr(..., "_model_meta")` in others still). The raw reference now lives on `rel.model_ref` and `rel.through_ref`, and `rel.model` / `rel.through` are read-only properties that return the resolved class or raise `TypeError` if it isn't resolved yet. The `TypeError` is deliberate: an `AttributeError` would be swallowed by `hasattr`/`getattr` and silently read as "no model" ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
9
+ - `ForeignKeyField.target_field` dropped its own unresolved-reference `TypeError` — the `rel.model` property raises it now, with a message naming the field ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
10
+ - `ManyToManyField._check_through_fields` reports an unresolved target by name instead of crashing on `related_model.model_options.object_name`, so a `through_fields` mistake on a not-yet-resolved relation produces the intended preflight message ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
11
+ - `ModelState` now raises `TypeError` (was `ValueError`) when a field's `to` or `through` refers to a model class instead of a string reference, and detects it by checking the reference directly rather than probing for `_model_meta` ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
12
+ - Removed `Field.concrete`. It was set to `self.column is not None` right after `column` was assigned the field's name, so it was `True` on every forward field and the checks that consulted it were dead. `bulk_create()` and `bulk_update()` now say what they actually reject — "bulk_update() cannot be used with many-to-many fields." rather than "can only be used with concrete fields." — and `Model.save(update_fields=...)` drops "non-concrete fields" from its error message. `ForeignObjectRel.concrete` and the unreachable virtual-field skip in `Model.__init__` are gone too ([af56244677](https://github.com/dropseed/plain/commit/af56244677))
13
+ - `OrderBy.asc()` and `.desc()` return the `OrderBy` instead of `None`, matching every other expression's `asc`/`desc` and making `expr.desc().asc()` and inline use work. They still take no options — passing `nulls_first` or `nulls_last` raises `TypeError` pointing you at the attributes on the `OrderBy` itself, rather than being silently ignored ([f00e707ed0](https://github.com/dropseed/plain/commit/f00e707ed0))
14
+ - Preflight expression checks no longer crash when a lookup path continues past a non-relation field — for example `Meta.ordering = ("name__nope",)` on a text field. The traversal now raises `FieldDoesNotExist` for the trailing part, which preflight already reports, instead of an `AttributeError` on `None` ([d9406dfc6f](https://github.com/dropseed/plain/commit/d9406dfc6f))
15
+ - `MigrationGraph.make_state()` raises `NodeNotFoundError` when the plan hits a placeholder node, instead of an `AttributeError` on `None` ([d9406dfc6f](https://github.com/dropseed/plain/commit/d9406dfc6f))
16
+ - The migration autodetector and `ProjectState` narrow on `RelatedField` / `ManyToManyField` / `ManyToManyRel` instead of `hasattr(field, "remote_field")` and `getattr(remote_field, "through", None)`. Rename handling only rewrites a reference when the old field is the same kind of relation, so a field swapped between a plain relation and an M2M during a rename no longer copies a mismatched reference across ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
17
+ - `ProjectState._get_related_models` reads a forward relation's target from `remote_field.model_ref` rather than `f.model` — which returned the model the field is _declared on_, not the one it points at ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
18
+ - `Meta._expire_cache` propagation to a related model no longer swallows `AttributeError` to handle the unresolved-string case; it checks `model_ref` up front, so a genuine `AttributeError` inside `_expire_cache` surfaces ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
19
+
20
+ ### Upgrade instructions
21
+
22
+ - If you read `rel.model` or `rel.through` (via `field.remote_field`) in code that runs before models are fully loaded — preflight checks, `contribute_to_class`, custom fields, migration operations — switch to `rel.model_ref` / `rel.through_ref` and check `isinstance(ref, str)`. After model loading completes, `rel.model` and `rel.through` behave as before.
23
+ - If you _assigned_ to `rel.model` or `rel.through`, assign to `rel.model_ref` / `rel.through_ref` instead — the plain names are now read-only properties.
24
+ - If you referenced `Field.concrete` or `ForeignObjectRel.concrete`, remove the check. `concrete` was `True` for every forward field and `False` only on reverse relations, which `get_fields()` doesn't return unless you pass `include_reverse=True`.
25
+ - If you asserted on the exact text of the `bulk_create()`, `bulk_update()`, or `save(update_fields=...)` errors, update the expected strings.
26
+ - If you caught `ValueError` from `ModelState` for a model-class `to`/`through` reference, catch `TypeError`.
27
+ - `OrderBy.asc()`/`.desc()` returning `self` is additive — existing calls that discarded the return value are unaffected.
28
+
3
29
  ## [0.116.0](https://github.com/dropseed/plain/releases/plain-postgres@0.116.0) (2026-09-04)
4
30
 
5
31
  ### What's changed
@@ -123,9 +123,6 @@ class Model(metaclass=ModelBase):
123
123
  from plain.postgres.fields.related import RelatedField
124
124
 
125
125
  is_related_object = False
126
- # Virtual field
127
- if field.name not in kwargs and field.column is None:
128
- continue
129
126
  if isinstance(field, RelatedField) and isinstance(
130
127
  field.remote_field, ForeignObjectRel
131
128
  ):
@@ -395,8 +392,8 @@ class Model(metaclass=ModelBase):
395
392
  non_model_fields = fields.difference(field_names)
396
393
  if non_model_fields:
397
394
  raise ValueError(
398
- "The following fields do not exist in this model, are m2m "
399
- "fields, or are non-concrete fields: "
395
+ "The following fields do not exist in this model or are m2m "
396
+ "fields: "
400
397
  f"{', '.join(sorted(non_model_fields))}"
401
398
  )
402
399
  deferred_in_update = fields & deferred_fields
@@ -755,11 +752,14 @@ class Model(metaclass=ModelBase):
755
752
 
756
753
  fields = cls._model_meta.many_to_many
757
754
 
758
- # Skip when the target model wasn't found.
759
- fields = (f for f in fields if isinstance(f.remote_field.model, ModelBase))
760
-
761
- # Skip when the relationship model wasn't found.
762
- fields = (f for f in fields if isinstance(f.remote_field.through, ModelBase))
755
+ # Skip when the target or relationship model wasn't found; the field's
756
+ # own preflight reports those.
757
+ fields = (
758
+ f
759
+ for f in fields
760
+ if not isinstance(f.remote_field.model_ref, str)
761
+ and not isinstance(f.remote_field.through_ref, str)
762
+ )
763
763
 
764
764
  for f in fields:
765
765
  signature = (
@@ -1033,7 +1033,11 @@ class Model(metaclass=ModelBase):
1033
1033
  fld = None
1034
1034
  for part in field.split(LOOKUP_SEP):
1035
1035
  try:
1036
- fld = _cls._model_meta.get_field(part) # ty: ignore[unresolved-attribute]
1036
+ if _cls is None:
1037
+ # The previous part was not a relation, so there is
1038
+ # no model left to look the next part up on.
1039
+ raise FieldDoesNotExist(part)
1040
+ fld = _cls._model_meta.get_field(part)
1037
1041
  if isinstance(fld, RelatedField):
1038
1042
  _cls = fld.path_infos[-1].to_meta.model
1039
1043
  else:
@@ -1111,11 +1115,11 @@ class Model(metaclass=ModelBase):
1111
1115
 
1112
1116
  for f in cls._model_meta.many_to_many:
1113
1117
  # Skip nonexistent models.
1114
- if isinstance(f.remote_field.through, str):
1118
+ if isinstance(f.remote_field.through_ref, str):
1115
1119
  continue
1116
1120
 
1117
1121
  # Check if column name for the M2M field is too long for the database.
1118
- for m2m in f.remote_field.through._model_meta.fields:
1122
+ for m2m in f.remote_field.through_ref._model_meta.fields:
1119
1123
  rel_name = m2m.column
1120
1124
  if rel_name is not None and len(rel_name) > allowed_len:
1121
1125
  errors.append(
@@ -1698,11 +1698,23 @@ class OrderBy(Expression):
1698
1698
  self.nulls_last = None
1699
1699
  return self
1700
1700
 
1701
- def asc(self) -> None: # ty: ignore[invalid-method-override]
1701
+ def asc(self, **kwargs: Any) -> OrderBy:
1702
+ if kwargs:
1703
+ raise TypeError(
1704
+ "OrderBy.asc() takes no options; set nulls_first/nulls_last on "
1705
+ "the OrderBy itself."
1706
+ )
1702
1707
  self.descending = False
1708
+ return self
1703
1709
 
1704
- def desc(self) -> None: # ty: ignore[invalid-method-override]
1710
+ def desc(self, **kwargs: Any) -> OrderBy:
1711
+ if kwargs:
1712
+ raise TypeError(
1713
+ "OrderBy.desc() takes no options; set nulls_first/nulls_last on "
1714
+ "the OrderBy itself."
1715
+ )
1705
1716
  self.descending = True
1717
+ return self
1706
1718
 
1707
1719
 
1708
1720
  class Window(Expression):
@@ -115,7 +115,6 @@ class Field[T](RegisterLookupMixin):
115
115
  name: str
116
116
  # Set by set_attributes_from_name (called by contribute_to_class)
117
117
  column: str
118
- concrete: bool
119
118
  # Set by contribute_to_class
120
119
  model: type[Model]
121
120
 
@@ -376,7 +375,6 @@ class Field[T](RegisterLookupMixin):
376
375
  # The database column is the field name. A foreign key overrides this
377
376
  # method only to append the "_id" suffix to `column`.
378
377
  self.column = self.name
379
- self.concrete = self.column is not None
380
378
 
381
379
  def contribute_to_class(self, cls: type[Model], name: str) -> None:
382
380
  """
@@ -166,13 +166,13 @@ class RelatedField(FieldCacheMixin, Field):
166
166
 
167
167
  def _check_relation_model_exists(self) -> list[PreflightResult]:
168
168
  rel_is_missing = (
169
- self.remote_field.model not in self.meta.models_registry.get_models()
169
+ self.remote_field.model_ref not in self.meta.models_registry.get_models()
170
170
  )
171
- rel_is_string = isinstance(self.remote_field.model, str)
171
+ rel_is_string = isinstance(self.remote_field.model_ref, str)
172
172
  model_name = (
173
- self.remote_field.model
173
+ self.remote_field.model_ref
174
174
  if rel_is_string
175
- else self.remote_field.model.model_options.object_name
175
+ else self.remote_field.model_ref.model_options.object_name
176
176
  )
177
177
  if rel_is_missing and rel_is_string:
178
178
  return [
@@ -189,13 +189,11 @@ class RelatedField(FieldCacheMixin, Field):
189
189
 
190
190
  def _check_clashes(self) -> list[PreflightResult]:
191
191
  """Check accessor and reverse query name clashes."""
192
- from plain.postgres.base import ModelBase
193
-
194
192
  errors: list[PreflightResult] = []
195
193
 
196
- # f.remote_field.model may be a string instead of a model. Skip if
197
- # model name is not resolved.
198
- if not isinstance(self.remote_field.model, ModelBase):
194
+ # Skip if the target model is still an unresolved string reference;
195
+ # _check_relation_model_exists reports that case.
196
+ if isinstance(self.remote_field.model_ref, str):
199
197
  return []
200
198
 
201
199
  # Consider that we are checking field `Model.foreign` and the models
@@ -259,13 +257,13 @@ class RelatedField(FieldCacheMixin, Field):
259
257
  def resolve_related_class(
260
258
  model: type[Model], related: type[Model], field: RelatedField
261
259
  ) -> None:
262
- field.remote_field.model = related
260
+ field.remote_field.model_ref = related
263
261
  field.do_related_class(related, model)
264
262
 
265
263
  lazy_related_operation(
266
264
  resolve_related_class,
267
265
  cls,
268
- self.remote_field.model,
266
+ self.remote_field.model_ref,
269
267
  field=self,
270
268
  )
271
269
 
@@ -456,14 +454,14 @@ class ForeignKeyField(ColumnField, RelatedField):
456
454
  name, path, args, kwargs = super().deconstruct()
457
455
  kwargs["on_delete"] = self.remote_field.on_delete
458
456
 
459
- if isinstance(self.remote_field.model, str):
460
- if "." in self.remote_field.model:
461
- package_label, model_name = self.remote_field.model.split(".")
457
+ if isinstance(self.remote_field.model_ref, str):
458
+ if "." in self.remote_field.model_ref:
459
+ package_label, model_name = self.remote_field.model_ref.split(".")
462
460
  kwargs["to"] = f"{package_label}.{model_name.lower()}"
463
461
  else:
464
- kwargs["to"] = self.remote_field.model.lower()
462
+ kwargs["to"] = self.remote_field.model_ref.lower()
465
463
  else:
466
- kwargs["to"] = self.remote_field.model.model_options.label_lower
464
+ kwargs["to"] = self.remote_field.model_ref.model_options.label_lower
467
465
 
468
466
  return name, path, args, kwargs
469
467
 
@@ -473,10 +471,6 @@ class ForeignKeyField(ColumnField, RelatedField):
473
471
  @cached_property
474
472
  def target_field(self) -> Field:
475
473
  """A foreign key points at exactly one column: the remote model's id."""
476
- if isinstance(self.remote_field.model, str):
477
- raise TypeError(
478
- f"Related model {self.remote_field.model!r} cannot be resolved"
479
- )
480
474
  return self.remote_field.model._model_meta.get_forward_field("id")
481
475
 
482
476
  def set_attributes_from_name(self, name: str) -> None:
@@ -640,14 +634,17 @@ class ManyToManyField(RelatedField):
640
634
  def _check_relationship_model(
641
635
  self, from_model: type[Model] | None = None, **kwargs: Any
642
636
  ) -> list[PreflightResult]:
643
- if hasattr(self.remote_field.through, "_model_meta"):
644
- qualified_model_name = f"{self.remote_field.through.model_options.package_label}.{self.remote_field.through.__name__}"
637
+ through_ref = self.remote_field.through_ref
638
+ if isinstance(through_ref, str):
639
+ qualified_model_name = through_ref
645
640
  else:
646
- qualified_model_name = self.remote_field.through
641
+ qualified_model_name = (
642
+ f"{through_ref.model_options.package_label}.{through_ref.__name__}"
643
+ )
647
644
 
648
645
  errors = []
649
646
 
650
- if self.remote_field.through not in self.meta.models_registry.get_models():
647
+ if through_ref not in self.meta.models_registry.get_models():
651
648
  # The relationship model is not installed.
652
649
  errors.append(
653
650
  PreflightResult(
@@ -668,7 +665,7 @@ class ManyToManyField(RelatedField):
668
665
  "where the field is attached to."
669
666
  )
670
667
  # Set some useful local variables
671
- to_model = resolve_relation(from_model, self.remote_field.model)
668
+ to_model = resolve_relation(from_model, self.remote_field.model_ref)
672
669
  from_model_name = from_model.model_options.object_name
673
670
  if isinstance(to_model, str):
674
671
  to_model_name = to_model
@@ -681,7 +678,7 @@ class ManyToManyField(RelatedField):
681
678
  # Count foreign keys in intermediate model
682
679
  if self_referential:
683
680
  seen_self = sum(
684
- from_model == field.remote_field.model
681
+ from_model == field.remote_field.model_ref
685
682
  for field in self.remote_field.through._model_meta.fields
686
683
  if isinstance(field, RelatedField)
687
684
  )
@@ -704,12 +701,12 @@ class ManyToManyField(RelatedField):
704
701
  else:
705
702
  # Count foreign keys in relationship model
706
703
  seen_from = sum(
707
- from_model == field.remote_field.model
704
+ from_model == field.remote_field.model_ref
708
705
  for field in self.remote_field.through._model_meta.fields
709
706
  if isinstance(field, RelatedField)
710
707
  )
711
708
  seen_to = sum(
712
- to_model == field.remote_field.model
709
+ to_model == field.remote_field.model_ref
713
710
  for field in self.remote_field.through._model_meta.fields
714
711
  if isinstance(field, RelatedField)
715
712
  )
@@ -797,7 +794,7 @@ class ManyToManyField(RelatedField):
797
794
  source, through, target = (
798
795
  from_model,
799
796
  self.remote_field.through,
800
- self.remote_field.model,
797
+ self.remote_field.model_ref,
801
798
  )
802
799
  source_field_name, target_field_name = self.remote_field.through_fields[
803
800
  :2
@@ -807,18 +804,24 @@ class ManyToManyField(RelatedField):
807
804
  (source_field_name, source),
808
805
  (target_field_name, target),
809
806
  ):
807
+ # The target may still be an unresolved "package.Model" string.
808
+ related_model_name = (
809
+ related_model
810
+ if isinstance(related_model, str)
811
+ else related_model.model_options.object_name
812
+ )
810
813
  possible_field_names: list[str] = []
811
814
  for f in through._model_meta.fields:
812
815
  if (
813
- hasattr(f, "remote_field")
814
- and getattr(f.remote_field, "model", None) == related_model
816
+ isinstance(f, RelatedField)
817
+ and f.remote_field.model_ref == related_model
815
818
  ):
816
819
  possible_field_names.append(f.name)
817
820
  if possible_field_names:
818
821
  fix = (
819
822
  "Did you mean one of the following foreign keys to '{}': "
820
823
  "{}?".format(
821
- related_model.model_options.object_name,
824
+ related_model_name,
822
825
  ", ".join(possible_field_names),
823
826
  )
824
827
  )
@@ -838,11 +841,11 @@ class ManyToManyField(RelatedField):
838
841
  else:
839
842
  if not (
840
843
  isinstance(field, RelatedField)
841
- and field.remote_field.model == related_model
844
+ and field.remote_field.model_ref == related_model
842
845
  ):
843
846
  errors.append(
844
847
  PreflightResult(
845
- fix=f"'{through.model_options.object_name}.{field_name}' is not a foreign key to '{related_model.model_options.object_name}'. {fix}",
848
+ fix=f"'{through.model_options.object_name}.{field_name}' is not a foreign key to '{related_model_name}'. {fix}",
846
849
  obj=self,
847
850
  id="fields.m2m_through_field_not_fk_to_model",
848
851
  )
@@ -851,7 +854,7 @@ class ManyToManyField(RelatedField):
851
854
  return errors
852
855
 
853
856
  def _check_table_uniqueness(self, **kwargs: Any) -> list[PreflightResult]:
854
- if isinstance(self.remote_field.through, str):
857
+ if isinstance(self.remote_field.through_ref, str):
855
858
  return []
856
859
  registered_tables = {
857
860
  model.model_options.db_table: model
@@ -880,19 +883,19 @@ class ManyToManyField(RelatedField):
880
883
  name, path, args, kwargs = super().deconstruct()
881
884
 
882
885
  # Lowercase model names as they should be treated as case-insensitive.
883
- if isinstance(self.remote_field.model, str):
884
- if "." in self.remote_field.model:
885
- package_label, model_name = self.remote_field.model.split(".")
886
+ if isinstance(self.remote_field.model_ref, str):
887
+ if "." in self.remote_field.model_ref:
888
+ package_label, model_name = self.remote_field.model_ref.split(".")
886
889
  kwargs["to"] = f"{package_label}.{model_name.lower()}"
887
890
  else:
888
- kwargs["to"] = self.remote_field.model.lower()
891
+ kwargs["to"] = self.remote_field.model_ref.lower()
889
892
  else:
890
- kwargs["to"] = self.remote_field.model.model_options.label_lower
893
+ kwargs["to"] = self.remote_field.model_ref.model_options.label_lower
891
894
 
892
- if isinstance(self.remote_field.through, str):
893
- kwargs["through"] = self.remote_field.through
895
+ if isinstance(self.remote_field.through_ref, str):
896
+ kwargs["through"] = self.remote_field.through_ref
894
897
  else:
895
- kwargs["through"] = self.remote_field.through.model_options.label
898
+ kwargs["through"] = self.remote_field.through_ref.model_options.label
896
899
 
897
900
  return name, path, args, kwargs
898
901
 
@@ -990,12 +993,12 @@ class ManyToManyField(RelatedField):
990
993
  def resolve_through_model(
991
994
  _: Any, model: type[Model], field: ManyToManyField
992
995
  ) -> None:
993
- field.remote_field.through = model
996
+ field.remote_field.through_ref = model
994
997
 
995
998
  lazy_related_operation(
996
999
  resolve_through_model,
997
1000
  cls,
998
- self.remote_field.through,
1001
+ self.remote_field.through_ref,
999
1002
  field=self,
1000
1003
  )
1001
1004
 
@@ -41,7 +41,6 @@ class ForeignObjectRel(FieldCacheMixin):
41
41
 
42
42
  # Field flags
43
43
  auto_created = True
44
- concrete = False
45
44
 
46
45
  # Reverse relations are always nullable (Plain can't enforce that a
47
46
  # foreign key on the related model points to this model).
@@ -49,8 +48,11 @@ class ForeignObjectRel(FieldCacheMixin):
49
48
  empty_strings_allowed = False
50
49
 
51
50
  # Type annotations for instance attributes
52
- model: type[Model]
53
51
  on_delete: OnDelete | None
52
+ # The target model as given to the field: a "package.Model" string (or
53
+ # "self") until lazy_related_operation resolves it to the class. Read
54
+ # `model` for the resolved class.
55
+ model_ref: str | type[Model]
54
56
 
55
57
  def __init__(
56
58
  self,
@@ -65,9 +67,7 @@ class ForeignObjectRel(FieldCacheMixin):
65
67
  # route every `self.field` read through its __get__. ForeignKeyRel
66
68
  # and ManyToManyRel below narrow this further, the same way.
67
69
  self.field: RelatedField = field
68
- # Initially may be a string, gets resolved to type[Model] by lazy_related_operation
69
- # (see related.py:250 where field.remote_field.model is overwritten)
70
- self.model = to # ty: ignore[invalid-assignment]
70
+ self.model_ref = to
71
71
  self.related_query_name = related_query_name
72
72
  self.on_delete = on_delete
73
73
 
@@ -77,6 +77,18 @@ class ForeignObjectRel(FieldCacheMixin):
77
77
  # __init__ as the field doesn't have its model yet. Calling these methods
78
78
  # before field.contribute_to_class() has been called will result in
79
79
  # AttributeError
80
+ @property
81
+ def model(self) -> type[Model]:
82
+ """The resolved target model class."""
83
+ # Deliberately not AttributeError: getattr(rel, "model", None) and
84
+ # hasattr() would swallow that and silently report "no model".
85
+ if isinstance(self.model_ref, str):
86
+ raise TypeError(
87
+ f"Related model {self.model_ref!r} for {self.field!r} has not been "
88
+ "resolved yet; use model_ref to read the unresolved reference."
89
+ )
90
+ return self.model_ref
91
+
80
92
  @cached_property
81
93
  def name(self) -> str:
82
94
  return self.field.related_query_name()
@@ -113,7 +125,7 @@ class ForeignObjectRel(FieldCacheMixin):
113
125
  def identity(self) -> tuple[Any, ...]:
114
126
  return (
115
127
  self.field,
116
- self.model,
128
+ self.model_ref,
117
129
  self.related_query_name,
118
130
  self.on_delete,
119
131
  self.symmetrical,
@@ -203,8 +215,10 @@ class ManyToManyRel(ForeignObjectRel):
203
215
  """
204
216
 
205
217
  # Type annotations for instance attributes
206
- through: type[Model]
207
218
  through_fields: tuple[str, str] | None
219
+ # The intermediary model as given to the field; a string until
220
+ # lazy_related_operation resolves it. Read `through` for the class.
221
+ through_ref: str | type[Model]
208
222
 
209
223
  def __init__(
210
224
  self,
@@ -226,9 +240,7 @@ class ManyToManyRel(ForeignObjectRel):
226
240
  # annotation).
227
241
  self.field: ManyToManyField
228
242
 
229
- # Initially may be a string, gets resolved to type[Model] by lazy_related_operation
230
- # (see related.py:1143 where field.remote_field.through is overwritten)
231
- self.through = through # ty: ignore[invalid-assignment]
243
+ self.through_ref = through
232
244
  self.through_fields = through_fields
233
245
 
234
246
  self.symmetrical = symmetrical
@@ -236,6 +248,17 @@ class ManyToManyRel(ForeignObjectRel):
236
248
  @property
237
249
  def identity(self) -> tuple[Any, ...]:
238
250
  return super().identity + (
239
- self.through,
251
+ self.through_ref,
240
252
  make_hashable(self.through_fields),
241
253
  )
254
+
255
+ @property
256
+ def through(self) -> type[Model]:
257
+ """The resolved intermediary model class."""
258
+ # Deliberately not AttributeError, for the same reason as `model`.
259
+ if isinstance(self.through_ref, str):
260
+ raise TypeError(
261
+ f"Through model {self.through_ref!r} for {self.field!r} has not been "
262
+ "resolved yet; use through_ref to read the unresolved reference."
263
+ )
264
+ return self.through_ref
@@ -178,11 +178,10 @@ class Meta:
178
178
  # ideally, we'd just ask for field.related_model. However, related_model
179
179
  # is a cached property, and all the models haven't been loaded yet, so
180
180
  # we need to make sure we don't cache a string reference.
181
- if isinstance(field, RelatedField) and field.remote_field.model:
182
- try:
183
- field.remote_field.model._model_meta._expire_cache(forward=False)
184
- except AttributeError:
185
- pass
181
+ if isinstance(field, RelatedField):
182
+ remote_model = field.remote_field.model_ref
183
+ if not isinstance(remote_model, str):
184
+ remote_model._model_meta._expire_cache(forward=False)
186
185
  self._expire_cache()
187
186
  else:
188
187
  self._expire_cache(reverse=False)
@@ -326,8 +325,8 @@ class Meta:
326
325
  if isinstance(f, RelatedField)
327
326
  )
328
327
  for f in fields_with_relations:
329
- if not isinstance(f.remote_field.model, str):
330
- remote_label = f.remote_field.model.model_options.label
328
+ if not isinstance(f.remote_field.model_ref, str):
329
+ remote_label = f.remote_field.model_ref.model_options.label
331
330
  related_objects_graph[remote_label].append(f)
332
331
 
333
332
  for model in all_models:
@@ -139,7 +139,7 @@ class MigrationAutodetector:
139
139
  fields_def = []
140
140
  for name, field in sorted(fields.items()):
141
141
  deconstruction = self.deep_deconstruct(field)
142
- if isinstance(field, RelatedField) and field.remote_field.model:
142
+ if isinstance(field, RelatedField) and field.remote_field.model_ref:
143
143
  deconstruction[2].pop("to", None)
144
144
  fields_def.append(deconstruction)
145
145
  return fields_def
@@ -244,11 +244,9 @@ class MigrationAutodetector:
244
244
  )
245
245
  old_model_state = self.from_state.models[package_label, old_model_name]
246
246
  for field_name, field in old_model_state.fields.items():
247
- if hasattr(field, "remote_field") and getattr(
248
- field.remote_field, "through", None
249
- ):
247
+ if isinstance(field, ManyToManyField):
250
248
  through_key = resolve_relation(
251
- field.remote_field.through, # ty: ignore[unresolved-attribute]
249
+ field.remote_field.through_ref,
252
250
  package_label,
253
251
  model_name,
254
252
  )
@@ -537,7 +535,7 @@ class MigrationAutodetector:
537
535
  related_fields = {}
538
536
  for field_name, field in model_state.fields.items():
539
537
  if isinstance(field, RelatedField):
540
- if field.remote_field.model:
538
+ if field.remote_field.model_ref:
541
539
  related_fields[field_name] = field
542
540
  if isinstance(field.remote_field, ManyToManyRel):
543
541
  related_fields[field_name] = field
@@ -639,7 +637,7 @@ class MigrationAutodetector:
639
637
  related_fields = {}
640
638
  for field_name, field in model_state.fields.items():
641
639
  if isinstance(field, RelatedField):
642
- if field.remote_field.model:
640
+ if field.remote_field.model_ref:
643
641
  related_fields[field_name] = field
644
642
  if isinstance(field.remote_field, ManyToManyRel):
645
643
  related_fields[field_name] = field
@@ -720,7 +718,7 @@ class MigrationAutodetector:
720
718
  if (
721
719
  isinstance(field, RelatedField)
722
720
  and field.remote_field
723
- and field.remote_field.model
721
+ and field.remote_field.model_ref
724
722
  and "to" in old_field_dec[2]
725
723
  ):
726
724
  old_rel_to = old_field_dec[2]["to"]
@@ -790,7 +788,7 @@ class MigrationAutodetector:
790
788
  (package_label, model_name, field_name, False)
791
789
  ]
792
790
  # Fields that are foreignkeys/m2ms depend on stuff.
793
- if isinstance(field, RelatedField) and field.remote_field.model:
791
+ if isinstance(field, RelatedField) and field.remote_field.model_ref:
794
792
  dependencies.extend(
795
793
  self._get_dependencies_for_foreign_key(
796
794
  package_label,
@@ -888,16 +886,16 @@ class MigrationAutodetector:
888
886
  dependencies: list[tuple[str, str, str | None, bool | str]] = []
889
887
  # Implement any model renames on relations; these are handled by RenameModel
890
888
  # so we need to exclude them from the comparison
891
- if hasattr(new_field, "remote_field") and getattr(
892
- new_field.remote_field, "model", None
893
- ):
889
+ if isinstance(new_field, RelatedField):
894
890
  rename_key = resolve_relation(
895
- new_field.remote_field.model, # ty: ignore[unresolved-attribute]
891
+ new_field.remote_field.model_ref,
896
892
  package_label,
897
893
  model_name,
898
894
  )
899
- if rename_key in self.renamed_models:
900
- new_field.remote_field.model = old_field.remote_field.model # ty: ignore[unresolved-attribute]
895
+ if rename_key in self.renamed_models and isinstance(
896
+ old_field, RelatedField
897
+ ):
898
+ new_field.remote_field.model_ref = old_field.remote_field.model_ref
901
899
  dependencies.extend(
902
900
  self._get_dependencies_for_foreign_key(
903
901
  package_label,
@@ -906,16 +904,18 @@ class MigrationAutodetector:
906
904
  self.to_state,
907
905
  )
908
906
  )
909
- if hasattr(new_field, "remote_field") and getattr(
910
- new_field.remote_field, "through", None
911
- ):
907
+ if isinstance(new_field, ManyToManyField):
912
908
  rename_key = resolve_relation(
913
- new_field.remote_field.through, # ty: ignore[unresolved-attribute]
909
+ new_field.remote_field.through_ref,
914
910
  package_label,
915
911
  model_name,
916
912
  )
917
- if rename_key in self.renamed_models:
918
- new_field.remote_field.through = old_field.remote_field.through # ty: ignore[unresolved-attribute]
913
+ if rename_key in self.renamed_models and isinstance(
914
+ old_field, ManyToManyField
915
+ ):
916
+ new_field.remote_field.through_ref = (
917
+ old_field.remote_field.through_ref
918
+ )
919
919
  old_field_dec = self.deep_deconstruct(old_field)
920
920
  new_field_dec = self.deep_deconstruct(new_field)
921
921
  # Exclude non_migration_attrs (allow_null, default, on_delete, choices,
@@ -1004,7 +1004,7 @@ class MigrationAutodetector:
1004
1004
  ) -> list[tuple[str, str, str | None, bool | str]]:
1005
1005
  remote_field_model = None
1006
1006
  if isinstance(field, RelatedField):
1007
- remote_field_model = field.remote_field.model
1007
+ remote_field_model = field.remote_field.model_ref
1008
1008
  else:
1009
1009
  relations = project_state.relations[package_label, model_name]
1010
1010
  for (remote_package_label, remote_model_name), fields in relations.items():
@@ -1027,7 +1027,7 @@ class MigrationAutodetector:
1027
1027
  field.remote_field, ManyToManyRel
1028
1028
  ):
1029
1029
  through_package_label, through_object_name = resolve_relation(
1030
- field.remote_field.through,
1030
+ field.remote_field.through_ref,
1031
1031
  package_label,
1032
1032
  model_name,
1033
1033
  )