plain.postgres 0.103.4__tar.gz → 0.104.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 (213) hide show
  1. plain_postgres-0.103.4/plain/postgres/README.md → plain_postgres-0.104.0/PKG-INFO +30 -0
  2. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/CHANGELOG.md +31 -0
  3. plain_postgres-0.103.4/PKG-INFO → plain_postgres-0.104.0/plain/postgres/README.md +17 -13
  4. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -0
  5. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/base.py +47 -56
  6. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/constraints.py +1 -1
  7. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/base.py +35 -29
  8. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/related.py +38 -58
  9. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/related_descriptors.py +101 -79
  10. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/related_lookups.py +1 -1
  11. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/related_managers.py +22 -32
  12. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/temporal.py +3 -2
  13. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/forms.py +2 -2
  14. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/meta.py +4 -26
  15. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/query.py +16 -21
  16. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sources.py +4 -0
  17. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sql/compiler.py +3 -3
  18. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sql/query.py +5 -9
  19. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/pyproject.toml +1 -1
  20. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/delete.py +0 -5
  21. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/relationships.py +0 -2
  22. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/trees.py +0 -1
  23. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/urls.py +5 -5
  24. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/views.py +5 -4
  25. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/urls.py +3 -3
  26. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_connection_lifecycle.py +14 -14
  27. plain_postgres-0.104.0/tests/internal/test_fk_characterization.py +156 -0
  28. plain_postgres-0.104.0/tests/public/test_deferred_loading.py +91 -0
  29. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_delete_behaviors.py +7 -9
  30. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_modelform_roundtrip.py +19 -19
  31. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_related.py +197 -2
  32. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/.gitignore +0 -0
  33. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/CLAUDE.md +0 -0
  34. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/LICENSE +0 -0
  35. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/README.md +0 -0
  36. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/__init__.py +0 -0
  37. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/adapters.py +0 -0
  38. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  39. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/aggregates.py +0 -0
  40. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/__init__.py +0 -0
  41. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/converge.py +0 -0
  42. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/core.py +0 -0
  43. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/decorators.py +0 -0
  44. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/diagnose.py +0 -0
  45. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/migrations.py +0 -0
  46. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/schema.py +0 -0
  47. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/cli/sync.py +0 -0
  48. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/config.py +0 -0
  49. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/connection.py +0 -0
  50. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/constants.py +0 -0
  51. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/convergence/__init__.py +0 -0
  52. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/convergence/analysis.py +0 -0
  53. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/convergence/fixes.py +0 -0
  54. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/convergence/planning.py +0 -0
  55. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/database_url.py +0 -0
  56. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/db.py +0 -0
  57. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/ddl.py +0 -0
  58. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/default_settings.py +0 -0
  59. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/deletion.py +0 -0
  60. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/dialect.py +0 -0
  61. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/entrypoints.py +0 -0
  62. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/enums.py +0 -0
  63. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/exceptions.py +0 -0
  64. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/expressions.py +0 -0
  65. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/__init__.py +0 -0
  66. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/binary.py +0 -0
  67. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/boolean.py +0 -0
  68. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/duration.py +0 -0
  69. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/encrypted.py +0 -0
  70. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/json.py +0 -0
  71. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/mixins.py +0 -0
  72. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/network.py +0 -0
  73. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/numeric.py +0 -0
  74. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/primary_key.py +0 -0
  75. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  76. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/reverse_related.py +0 -0
  77. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/text.py +0 -0
  78. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/timezones.py +0 -0
  79. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/fields/uuid.py +0 -0
  80. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/__init__.py +0 -0
  81. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/comparison.py +0 -0
  82. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/datetime.py +0 -0
  83. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/math.py +0 -0
  84. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/mixins.py +0 -0
  85. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/random.py +0 -0
  86. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/text.py +0 -0
  87. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/uuid.py +0 -0
  88. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/functions/window.py +0 -0
  89. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/indexes.py +0 -0
  90. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/__init__.py +0 -0
  91. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/__init__.py +0 -0
  92. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  93. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  94. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  95. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/context.py +0 -0
  96. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/helpers.py +0 -0
  97. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/ownership.py +0 -0
  98. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/runner.py +0 -0
  99. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/health/types.py +0 -0
  100. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/introspection/schema.py +0 -0
  101. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/lookups.py +0 -0
  102. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/middleware.py +0 -0
  103. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/__init__.py +0 -0
  104. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/autodetector.py +0 -0
  105. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/exceptions.py +0 -0
  106. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/executor.py +0 -0
  107. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/graph.py +0 -0
  108. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/loader.py +0 -0
  109. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/migration.py +0 -0
  110. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  111. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/base.py +0 -0
  112. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/fields.py +0 -0
  113. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/models.py +0 -0
  114. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/special.py +0 -0
  115. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/optimizer.py +0 -0
  116. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/questioner.py +0 -0
  117. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/recorder.py +0 -0
  118. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/serializer.py +0 -0
  119. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/state.py +0 -0
  120. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/utils.py +0 -0
  121. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/migrations/writer.py +0 -0
  122. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/options.py +0 -0
  123. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/otel.py +0 -0
  124. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/preflight.py +0 -0
  125. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/query_utils.py +0 -0
  126. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/registry.py +0 -0
  127. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/schema.py +0 -0
  128. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sql/__init__.py +0 -0
  129. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sql/constants.py +0 -0
  130. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sql/datastructures.py +0 -0
  131. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/sql/where.py +0 -0
  132. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/test/__init__.py +0 -0
  133. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/test/database.py +0 -0
  134. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/test/pytest.py +0 -0
  135. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/transaction.py +0 -0
  136. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/types.py +0 -0
  137. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/types.pyi +0 -0
  138. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/plain/postgres/utils.py +0 -0
  139. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/forms.py +0 -0
  140. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  141. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  142. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  143. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  144. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  145. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  146. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  147. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  148. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  149. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  150. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  151. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  152. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  153. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  154. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  155. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  156. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  157. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  158. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/migrations/__init__.py +0 -0
  159. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/__init__.py +0 -0
  160. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/constraints.py +0 -0
  161. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/defaults.py +0 -0
  162. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/encrypted.py +0 -0
  163. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/forms.py +0 -0
  164. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/indexes.py +0 -0
  165. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/iteration.py +0 -0
  166. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/mixins.py +0 -0
  167. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/nullability.py +0 -0
  168. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/querysets.py +0 -0
  169. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/storage_parameters.py +0 -0
  170. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/examples/models/unregistered.py +0 -0
  171. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/app/settings.py +0 -0
  172. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/conftest.py +0 -0
  173. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/conftest_convergence.py +0 -0
  174. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  175. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_autodetector_type_change.py +0 -0
  176. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_connection_isolation.py +0 -0
  177. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_connection_pool.py +0 -0
  178. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_constraint_violation_error.py +0 -0
  179. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence.py +0 -0
  180. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_constraints.py +0 -0
  181. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_defaults.py +0 -0
  182. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_fk.py +0 -0
  183. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_indexes.py +0 -0
  184. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_nullability.py +0 -0
  185. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  186. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_convergence_timeouts.py +0 -0
  187. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_db_expression_defaults.py +0 -0
  188. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_diagnose.py +0 -0
  189. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_executor_connection_hook.py +0 -0
  190. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_health.py +0 -0
  191. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_introspection.py +0 -0
  192. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_literal_default_persistence.py +0 -0
  193. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_management_connection.py +0 -0
  194. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_migration_executor.py +0 -0
  195. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_no_callable_defaults.py +0 -0
  196. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_otel_metrics.py +0 -0
  197. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  198. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  199. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_schema_normalize_type.py +0 -0
  200. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/internal/test_schema_timeouts.py +0 -0
  201. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_database_url.py +0 -0
  202. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_encrypted_fields.py +0 -0
  203. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_exceptions.py +0 -0
  204. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_field_defaults.py +0 -0
  205. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_functions_uuid.py +0 -0
  206. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_iterator.py +0 -0
  207. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_m2m.py +0 -0
  208. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_manager_assignment.py +0 -0
  209. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_mixins.py +0 -0
  210. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_queryset_repr.py +0 -0
  211. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_random_string_field.py +0 -0
  212. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_raw_query.py +0 -0
  213. {plain_postgres-0.103.4 → plain_postgres-0.104.0}/tests/public/test_read_only_transactions.py +0 -0
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.104.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0,>=0.134.0
10
+ Requires-Dist: psycopg-pool>=3.2
11
+ Requires-Dist: psycopg>=3.2
12
+ Description-Content-Type: text/markdown
13
+
1
14
  # plain.postgres
2
15
 
3
16
  **Model your data and store it in a database.**
@@ -410,6 +423,8 @@ posts = Post.query.all()
410
423
  posts = Post.query.defer("body").all()
411
424
  ```
412
425
 
426
+ Reading any deferred field on an instance loads every still-missing column in a single query, not just the field you asked for. Defer columns you don't intend to access on that instance, and prefer `.values()` / `.values_list()` when you want one column without ever materializing the rest. The foreign key column is the exception: accessing a deferred foreign key loads only the foreign key column, so the partial-related-instance shortcut stays cheap.
427
+
413
428
  #### Use `.iterator()` for large result sets
414
429
 
415
430
  Process rows in chunks instead of loading everything into memory.
@@ -929,6 +944,21 @@ class Book(postgres.Model):
929
944
  tags = types.ManyToManyField("Tag")
930
945
  ```
931
946
 
947
+ ### Foreign key access
948
+
949
+ Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
950
+
951
+ ```python
952
+ book = Book.query.get(id=1)
953
+ book.author # no query — a partial Author instance
954
+ book.author.id # no query — the foreign key value
955
+ book.author.name # one query — loads the rest of the row
956
+ ```
957
+
958
+ The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
959
+
960
+ The partial-instance shortcut relies on the database guaranteeing the row exists. A foreign key declared with `db_constraint=False` has no such guarantee, so it is queried on access instead — a stale key raises `DoesNotExist` right away rather than yielding a placeholder.
961
+
932
962
  ### Reverse relationships
933
963
 
934
964
  When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
@@ -1,5 +1,36 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.104.0](https://github.com/dropseed/plain/releases/plain-postgres@0.104.0) (2026-05-22)
4
+
5
+ ### What's changed
6
+
7
+ - **Foreign keys now return a partial related instance instead of an `_id` attribute.** Reading `book.author` is query-free — it returns an `Author` instance with only its primary key loaded. `book.author.id` gives the foreign key value, and accessing any other field (`book.author.name`) triggers a single deferred-load query for the whole row. The descriptor caches the resolved object so subsequent accesses do not re-query. `select_related()` continues to populate the cache up front for loops. ([b3be028b7b](https://github.com/dropseed/plain/commit/b3be028b7b))
8
+ - **The `<fk>_id` shadow attribute is gone.** There is no `book.author_id` anymore — the raw foreign key column lives in `instance.__dict__` under the field name (`book.__dict__["author"]`) and is reached through the descriptor. `book.author.id` is the replacement read path and is type-checked because `book.author` is an `Author`. Writes that previously assigned `book.author_id = 5` now need to assign the key (or a related instance) through `book.author = 5` — the descriptor accepts a bare primary key value, a model instance, or `None`. ([3baa1c0404](https://github.com/dropseed/plain/commit/3baa1c0404), [b3be028b7b](https://github.com/dropseed/plain/commit/b3be028b7b))
9
+ - **`Field.attname` and `Field.get_attname()` are removed.** Every internal use was the same as `Field.name`; the dual-keying only existed to track the `_id` suffix on foreign keys, which now lives on `Field.column` instead. Use `field.name` for the attribute (and instance-dict key) and `field.column` for the database column. ([6a462a874c](https://github.com/dropseed/plain/commit/6a462a874c), [e556bb7d63](https://github.com/dropseed/plain/commit/e556bb7d63))
10
+ - **Foreign keys with `db_constraint=False` are queried on access** rather than returning a partial instance. Without a database FK constraint a stale key cannot be assumed valid, so the descriptor issues a `SELECT` and raises `RelatedObjectDoesNotExist` immediately if the row is missing — the same exception an empty foreign key raises. Constrained foreign keys (the default) keep the no-query partial-instance shortcut. ([d341d902b4](https://github.com/dropseed/plain/commit/d341d902b4))
11
+ - **Reading a deferred field now hydrates every still-missing concrete column in one query**, not just the field you accessed. This is what makes the partial foreign-key instance cheap to use beyond its primary key — first non-key access loads the whole row. The foreign key column itself is the exception and is loaded on its own when deferred via `.only()` / `.defer()`, so the partial-instance shortcut stays cheap. ([2440e415cb](https://github.com/dropseed/plain/commit/2440e415cb))
12
+ - The descriptor's deferred foreign-key load now goes through `_get_raw_value()`, so a `.only()` queryset that excludes the FK column still resolves the related object on access without mistaking the deferred column for `NULL`. ([d9765979ba](https://github.com/dropseed/plain/commit/d9765979ba))
13
+ - `Field.__delete__` reordered so a missing entry raises `AttributeError` without first wiping the related-object cache — repeated `del`s no longer silently evict cached descriptors. ([b8949f00d0](https://github.com/dropseed/plain/commit/b8949f00d0))
14
+ - Internal cleanup: removed the unused `ForeignKeyField.reverse_related_fields` property, the dead composite-key branches in foreign-key resolution, and the unreachable code left over from the `attname` collapse. ([98b27c5f8a](https://github.com/dropseed/plain/commit/98b27c5f8a), [92e905f25e](https://github.com/dropseed/plain/commit/92e905f25e), [bfcac9a435](https://github.com/dropseed/plain/commit/bfcac9a435))
15
+
16
+ ### Upgrade instructions
17
+
18
+ - **Audit every `<fk>_id` access** — these attributes no longer exist. Reads (`obj.author_id`) and especially writes (`obj.author_id = 5`) need to move to the field name: `obj.author.id` for reads, `obj.author = 5` for writes. **Pay particular attention to assignments under `# type: ignore` or other suppression** — a stray `obj.author_id = ...` write now creates a plain Python attribute on the instance and silently does nothing on save, because the ORM only persists the descriptor-backed value at `obj.author`. Grep for `_id =`, `_id=` (in `.create()` / `.update()` kwargs), and `_id` template/queryset references on your models. Querysets and lookups (`Author.query.filter(book__author_id=5)`) are unaffected — only attribute access on instances changed.
19
+ - **Stop reading `Field.attname` / calling `Field.get_attname()`.** Any custom field or introspection code that referenced these attributes must switch to `field.name` (the Python attribute) or `field.column` (the database column).
20
+ - **Foreign keys declared with `db_constraint=False` now issue a query on first access** (previously: no query, partial instance). If you relied on accessing a constraint-less FK without a query, the behavior is intentionally stricter — a stale key now fails fast with `RelatedObjectDoesNotExist` instead of yielding a phantom instance that broke only on field access. Use `select_related()` to load these up front in loops.
21
+ - **`.only()` / `.defer()` field lists**: deferred non-FK columns no longer load one-at-a-time. If you `.defer("body")` and later read `instance.body`, the resulting query now refreshes every still-deferred column in the same instance — defer only the columns you do not intend to read.
22
+
23
+ ## [0.103.5](https://github.com/dropseed/plain/releases/plain-postgres@0.103.5) (2026-05-19)
24
+
25
+ ### What's changed
26
+
27
+ - **Pooled connections are now validated on checkout.** The connection pool runs `check_connection` on each `getconn()`, so a connection closed server-side while idle in the pool (a server or pooler idle timeout) is discarded and replaced rather than handed out dead. This closes a class of `OperationalError: the connection is closed` failures on the first query of a request after an idle period. ([31ad84f423](https://github.com/dropseed/plain/commit/31ad84f423))
28
+ - Standardized `__all__` in `forms.py` to a list for consistency with the rest of the codebase. ([64ee8a4de0](https://github.com/dropseed/plain/commit/64ee8a4de0))
29
+
30
+ ### Upgrade instructions
31
+
32
+ - No changes required.
33
+
3
34
  ## [0.103.4](https://github.com/dropseed/plain/releases/plain-postgres@0.103.4) (2026-05-12)
4
35
 
5
36
  ### What's changed
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.103.4
4
- Summary: Model your data and store it in a database.
5
- Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
- License-Expression: BSD-3-Clause
7
- License-File: LICENSE
8
- Requires-Python: >=3.13
9
- Requires-Dist: plain<1.0.0,>=0.134.0
10
- Requires-Dist: psycopg-pool>=3.2
11
- Requires-Dist: psycopg>=3.2
12
- Description-Content-Type: text/markdown
13
-
14
1
  # plain.postgres
15
2
 
16
3
  **Model your data and store it in a database.**
@@ -423,6 +410,8 @@ posts = Post.query.all()
423
410
  posts = Post.query.defer("body").all()
424
411
  ```
425
412
 
413
+ Reading any deferred field on an instance loads every still-missing column in a single query, not just the field you asked for. Defer columns you don't intend to access on that instance, and prefer `.values()` / `.values_list()` when you want one column without ever materializing the rest. The foreign key column is the exception: accessing a deferred foreign key loads only the foreign key column, so the partial-related-instance shortcut stays cheap.
414
+
426
415
  #### Use `.iterator()` for large result sets
427
416
 
428
417
  Process rows in chunks instead of loading everything into memory.
@@ -942,6 +931,21 @@ class Book(postgres.Model):
942
931
  tags = types.ManyToManyField("Tag")
943
932
  ```
944
933
 
934
+ ### Foreign key access
935
+
936
+ Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
937
+
938
+ ```python
939
+ book = Book.query.get(id=1)
940
+ book.author # no query — a partial Author instance
941
+ book.author.id # no query — the foreign key value
942
+ book.author.name # one query — loads the rest of the row
943
+ ```
944
+
945
+ The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
946
+
947
+ The partial-instance shortcut relies on the database guaranteeing the row exists. A foreign key declared with `db_constraint=False` has no such guarantee, so it is queried on access instead — a stale key raises `DoesNotExist` right away rather than yielding a placeholder.
948
+
945
949
  ### Reverse relationships
946
950
 
947
951
  When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
@@ -49,6 +49,7 @@ Run `uv run plain docs postgres` for full workflow details.
49
49
  Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`).
50
50
 
51
51
  - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N
52
+ - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id`
52
53
  - Use `.annotate(Count(...))` instead of calling `.count()` per row
53
54
  - Fetch all data in the view — templates should never trigger queries
54
55
  - Use `.exists()` not `.count() > 0`, `.count()` not `len(qs)`
@@ -113,24 +113,21 @@ class Model(metaclass=ModelBase):
113
113
 
114
114
  is_related_object = False
115
115
  # Virtual field
116
- if field.attname not in kwargs and field.column is None:
116
+ if field.name not in kwargs and field.column is None:
117
117
  continue
118
118
  if isinstance(field, RelatedField) and isinstance(
119
119
  field.remote_field, ForeignObjectRel
120
120
  ):
121
121
  try:
122
- # Assume object instance was passed in.
122
+ # A foreign key is given by name -- either a related
123
+ # instance or a bare primary key value.
123
124
  rel_obj = kwargs.pop(field.name)
124
125
  is_related_object = True
125
126
  except KeyError:
126
- try:
127
- # Object instance wasn't passed in -- must be an ID.
128
- val = kwargs.pop(field.attname)
129
- except KeyError:
130
- val = field.get_default()
127
+ val = field.get_default()
131
128
  else:
132
129
  try:
133
- val = kwargs.pop(field.attname)
130
+ val = kwargs.pop(field.name)
134
131
  except KeyError:
135
132
  # This is done with an exception rather than the
136
133
  # default argument on pop because we don't want
@@ -145,15 +142,13 @@ class Model(metaclass=ModelBase):
145
142
  val = field.get_default()
146
143
 
147
144
  if is_related_object:
148
- # If we are passed a related instance, set it using the
149
- # field.name instead of field.attname (e.g. "user" instead of
150
- # "user_id") so that the object gets properly cached (and type
151
- # checked) by the RelatedObjectDescriptor.
145
+ # Assign through the descriptor so a related instance is
146
+ # cached and a bare key value is stored correctly.
152
147
  if rel_obj is not _DEFERRED:
153
148
  _setattr(self, field.name, rel_obj)
154
149
  else:
155
150
  if val is not _DEFERRED:
156
- _setattr(self, field.attname, val)
151
+ _setattr(self, field.name, val)
157
152
 
158
153
  # Handle any remaining kwargs (properties or virtual fields)
159
154
  property_names = meta._property_names
@@ -185,12 +180,12 @@ class Model(metaclass=ModelBase):
185
180
  if len(values) != len(cls._model_meta.concrete_fields):
186
181
  values_iter = iter(values)
187
182
  values = [
188
- next(values_iter) if f.attname in field_names else DEFERRED
183
+ next(values_iter) if f.name in field_names else DEFERRED
189
184
  for f in cls._model_meta.concrete_fields
190
185
  ]
191
186
  # Build kwargs dict from field names and values
192
187
  field_dict = dict(
193
- zip((f.attname for f in cls._model_meta.concrete_fields), values)
188
+ zip((f.name for f in cls._model_meta.concrete_fields), values)
194
189
  )
195
190
  new = cls(**field_dict)
196
191
  new._state.adding = False
@@ -269,9 +264,9 @@ class Model(metaclass=ModelBase):
269
264
  Return a set containing names of deferred fields for this instance.
270
265
  """
271
266
  return {
272
- f.attname
267
+ f.name
273
268
  for f in self._model_meta.concrete_fields
274
- if f.attname not in self.__dict__
269
+ if f.name not in self.__dict__
275
270
  }
276
271
 
277
272
  def refresh_from_db(self, fields: list[str] | None = None) -> None:
@@ -309,19 +304,19 @@ class Model(metaclass=ModelBase):
309
304
  db_instance_qs = db_instance_qs.only(*fields)
310
305
  elif deferred_fields:
311
306
  fields = [
312
- f.attname
307
+ f.name
313
308
  for f in self._model_meta.concrete_fields
314
- if f.attname not in deferred_fields
309
+ if f.name not in deferred_fields
315
310
  ]
316
311
  db_instance_qs = db_instance_qs.only(*fields)
317
312
 
318
313
  db_instance = db_instance_qs.get()
319
314
  non_loaded_fields = db_instance.get_deferred_fields()
320
315
  for field in self._model_meta.concrete_fields:
321
- if field.attname in non_loaded_fields:
316
+ if field.name in non_loaded_fields:
322
317
  # This field wasn't refreshed - skip ahead.
323
318
  continue
324
- setattr(self, field.attname, getattr(db_instance, field.attname))
319
+ setattr(self, field.name, field.value_from_object(db_instance))
325
320
  # Clear cached foreign keys.
326
321
  if isinstance(field, RelatedField) and field.is_cached(self):
327
322
  field.delete_cached_value(self)
@@ -346,7 +341,7 @@ class Model(metaclass=ModelBase):
346
341
  field = self._model_meta.get_forward_field(field_name)
347
342
  except FieldDoesNotExist:
348
343
  return getattr(self, field_name)
349
- return getattr(self, field.attname)
344
+ return field.value_from_object(self)
350
345
 
351
346
  def save(
352
347
  self,
@@ -393,7 +388,7 @@ class Model(metaclass=ModelBase):
393
388
  field_names = set()
394
389
  for field in self._model_meta.concrete_fields:
395
390
  if not field.primary_key and not hasattr(field, "through"):
396
- field_names.add(field.attname)
391
+ field_names.add(field.name)
397
392
  loaded_fields = field_names.difference(deferred_fields)
398
393
  if loaded_fields:
399
394
  update_fields = frozenset(loaded_fields)
@@ -454,11 +449,7 @@ class Model(metaclass=ModelBase):
454
449
  non_pks = [f for f in meta.local_concrete_fields if not f.primary_key]
455
450
 
456
451
  if update_fields:
457
- non_pks = [
458
- f
459
- for f in non_pks
460
- if f.name in update_fields or f.attname in update_fields
461
- ]
452
+ non_pks = [f for f in non_pks if f.name in update_fields]
462
453
 
463
454
  id_field = meta.get_forward_field("id")
464
455
  id_val = self.id
@@ -469,7 +460,7 @@ class Model(metaclass=ModelBase):
469
460
  # stays None and the INSERT emits DEFAULT.
470
461
  if isinstance(id_field, DefaultableField) and id_field.has_default():
471
462
  id_val = id_field.get_default()
472
- setattr(self, id_field.attname, id_val)
463
+ setattr(self, id_field.name, id_val)
473
464
  id_set = id_val is not None
474
465
  if not id_set and (force_update or update_fields):
475
466
  raise ValueError("Cannot force an update in save() with no primary key.")
@@ -488,7 +479,7 @@ class Model(metaclass=ModelBase):
488
479
  if id_set and not force_insert:
489
480
  base_qs = meta.base_queryset
490
481
  values = [
491
- (f, (getattr(self, f.attname) if raw else f.pre_save(self, False)))
482
+ (f, (f.value_from_object(self) if raw else f.pre_save(self, False)))
492
483
  for f in non_pks
493
484
  ]
494
485
  # DATABASE_DEFAULT fields represent "let the DB produce this on
@@ -497,9 +488,7 @@ class Model(metaclass=ModelBase):
497
488
  # below handles them correctly. If the UPDATE *does* succeed, we
498
489
  # need to refresh those fields from the DB so the in-memory
499
490
  # instance doesn't keep the sentinel.
500
- db_default_attnames = [
501
- v[0].attname for v in values if v[1] is DATABASE_DEFAULT
502
- ]
491
+ db_default_names = [v[0].name for v in values if v[1] is DATABASE_DEFAULT]
503
492
  values = [v for v in values if v[1] is not DATABASE_DEFAULT]
504
493
  forced_update = bool(update_fields or force_update)
505
494
  updated = self._do_update(
@@ -511,8 +500,8 @@ class Model(metaclass=ModelBase):
511
500
  raise psycopg.DatabaseError(
512
501
  "Save with update_fields did not affect any rows."
513
502
  )
514
- if updated and db_default_attnames:
515
- self.refresh_from_db(fields=db_default_attnames)
503
+ if updated and db_default_names:
504
+ self.refresh_from_db(fields=db_default_names)
516
505
  if not updated:
517
506
  fields = meta.local_concrete_fields
518
507
  if not id_set:
@@ -523,7 +512,8 @@ class Model(metaclass=ModelBase):
523
512
  results = self._do_insert(meta.base_queryset, fields, returning_fields, raw)
524
513
  if results:
525
514
  for value, field in zip(results[0], returning_fields):
526
- setattr(self, field.attname, value)
515
+ assert field.name is not None
516
+ setattr(self, field.name, value)
527
517
  return updated
528
518
 
529
519
  def _do_update(
@@ -592,14 +582,16 @@ class Model(metaclass=ModelBase):
592
582
  f"{operation_name}() prohibited to prevent data loss due to unsaved "
593
583
  f"related object '{field.name}'."
594
584
  )
595
- elif getattr(self, field.attname) in field.empty_values:
585
+ elif field.value_from_object(self) in field.empty_values:
596
586
  # Set related object if it has been saved after an
597
587
  # assignment.
598
588
  setattr(self, field.name, obj)
599
589
  # If the relationship's pk/to_field was changed, clear the
600
- # cached relationship.
601
- if getattr(obj, field.target_field.attname) != getattr(
602
- self, field.attname
590
+ # cached relationship. Compare the cached object's key against
591
+ # the raw key value -- not getattr(self, field.name), which
592
+ # for a foreign key returns the related object, not the key.
593
+ if getattr(obj, field.target_field.name) != field.value_from_object(
594
+ self
603
595
  ):
604
596
  field.delete_cached_value(self)
605
597
 
@@ -624,14 +616,16 @@ class Model(metaclass=ModelBase):
624
616
  # blocks see the abort state even if the caller catches IntegrityError.
625
617
  with transaction.mark_for_rollback_on_error():
626
618
  count = self._model_meta.base_queryset.filter(id=self.id)._raw_delete()
627
- setattr(self, self._model_meta.get_forward_field("id").attname, None)
619
+ id_field = self._model_meta.get_forward_field("id")
620
+ assert id_field.name is not None
621
+ setattr(self, id_field.name, None)
628
622
  return count
629
623
 
630
624
  def get_field_display(self, field_name: str) -> str:
631
625
  """Get the display value for a field, especially useful for fields with choices."""
632
626
  # Get the field object from the field name
633
627
  field = self._model_meta.get_forward_field(field_name)
634
- value = getattr(self, field.attname)
628
+ value = field.value_from_object(self)
635
629
 
636
630
  # If field has no choices, just return the value as string
637
631
  if not hasattr(field, "flatchoices") or not field.flatchoices:
@@ -650,7 +644,7 @@ class Model(metaclass=ModelBase):
650
644
  exclude = set()
651
645
  meta = meta or self._model_meta
652
646
  return {
653
- field.name: Value(getattr(self, field.attname), field)
647
+ field.name: Value(field.value_from_object(self), field)
654
648
  for field in meta.local_concrete_fields
655
649
  if field.name not in exclude
656
650
  }
@@ -660,7 +654,7 @@ class Model(metaclass=ModelBase):
660
654
  raise ValueError(
661
655
  f"Unsaved model instance {self!r} cannot be used in an ORM query."
662
656
  )
663
- return getattr(self, field.remote_field.get_related_field().attname)
657
+ return getattr(self, field.remote_field.get_related_field().name)
664
658
 
665
659
  def clean(self) -> None:
666
660
  """
@@ -722,7 +716,7 @@ class Model(metaclass=ModelBase):
722
716
  lookup_kwargs = {}
723
717
  for field_name in unique_check:
724
718
  f = self._model_meta.get_forward_field(field_name)
725
- lookup_value = getattr(self, f.attname)
719
+ lookup_value = f.value_from_object(self)
726
720
  if lookup_value is None:
727
721
  # no value, skip the lookup
728
722
  continue
@@ -842,7 +836,7 @@ class Model(metaclass=ModelBase):
842
836
  for f in self._model_meta.fields:
843
837
  if f.name in exclude:
844
838
  continue
845
- if self.__dict__.get(f.attname) is DATABASE_DEFAULT:
839
+ if self.__dict__.get(f.name) is DATABASE_DEFAULT:
846
840
  exclude.add(f.name)
847
841
  elif f.auto_fills_on_save:
848
842
  exclude.add(f.name)
@@ -896,11 +890,11 @@ class Model(metaclass=ModelBase):
896
890
  continue
897
891
  # Skip validation for empty fields with required=False. The developer
898
892
  # is responsible for making sure they have a valid value.
899
- raw_value = getattr(self, f.attname)
893
+ raw_value = f.value_from_object(self)
900
894
  if not f.required and raw_value in f.empty_values:
901
895
  continue
902
896
  try:
903
- setattr(self, f.attname, f.clean(raw_value, self))
897
+ setattr(self, f.name, f.clean(raw_value, self))
904
898
  except ValidationError as e:
905
899
  errors[f.name] = e.error_list
906
900
 
@@ -1000,12 +994,12 @@ class Model(metaclass=ModelBase):
1000
994
 
1001
995
  @classmethod
1002
996
  def _check_field_name_clashes(cls) -> list[PreflightResult]:
1003
- """Reject fields that share a name or attname within the same model."""
997
+ """Reject fields that share a name within the same model."""
1004
998
  errors: list[PreflightResult] = []
1005
- used_fields = {} # name or attname -> field
999
+ used_fields = {} # name -> field
1006
1000
 
1007
1001
  for f in cls._model_meta.local_fields:
1008
- clash = used_fields.get(f.name) or used_fields.get(f.attname) or None
1002
+ clash = used_fields.get(f.name)
1009
1003
  # Note that we may detect clash between user-defined non-unique
1010
1004
  # field "id" and automatically added unique field "id", both
1011
1005
  # defined at the same model. This special case is considered in
@@ -1023,7 +1017,6 @@ class Model(metaclass=ModelBase):
1023
1017
  )
1024
1018
  )
1025
1019
  used_fields[f.name] = f
1026
- used_fields[f.attname] = f
1027
1020
 
1028
1021
  return errors
1029
1022
 
@@ -1082,7 +1075,7 @@ class Model(metaclass=ModelBase):
1082
1075
  errors: list[PreflightResult] = []
1083
1076
  property_names = cls._model_meta._property_names
1084
1077
  related_field_accessors = (
1085
- f.get_attname()
1078
+ f.name
1086
1079
  for f in cls._model_meta._get_fields(reverse=False)
1087
1080
  if isinstance(f, RelatedField)
1088
1081
  )
@@ -1164,8 +1157,6 @@ class Model(metaclass=ModelBase):
1164
1157
  forward_fields_map: dict[str, Field] = {}
1165
1158
  for field in cls._model_meta._get_fields(reverse=False):
1166
1159
  forward_fields_map[field.name] = field
1167
- if hasattr(field, "attname"):
1168
- forward_fields_map[field.attname] = field
1169
1160
 
1170
1161
  errors: list[PreflightResult] = []
1171
1162
  for field_name in fields:
@@ -1277,7 +1268,7 @@ class Model(metaclass=ModelBase):
1277
1268
  meta = cls._model_meta
1278
1269
  valid_fields = set(
1279
1270
  chain.from_iterable(
1280
- (f.name, f.attname)
1271
+ (f.name,)
1281
1272
  if not (f.auto_created and not f.concrete)
1282
1273
  else (f.field.related_query_name(),)
1283
1274
  for f in chain(meta.fields, meta.related_objects)
@@ -363,7 +363,7 @@ class UniqueConstraint(BaseConstraint):
363
363
  if exclude and field_name in exclude:
364
364
  return
365
365
  field = model._model_meta.get_forward_field(field_name)
366
- lookup_value = getattr(instance, field.attname)
366
+ lookup_value = field.value_from_object(instance)
367
367
  if lookup_value is None:
368
368
  # A composite constraint containing NULL value cannot cause
369
369
  # a violation since NULL != NULL in SQL.
@@ -82,15 +82,12 @@ def _load_field(
82
82
 
83
83
  # A guide to Field parameters:
84
84
  #
85
- # * name: The name of the field specified in the model.
86
- # * attname: The attribute to use on the model object. This is the same as
87
- # "name", except in the case of ForeignKeys, where "_id" is
88
- # appended.
89
- # * column: The database column for this field. This is the same as
90
- # "attname".
91
- #
92
- # Code that introspects values, or does other dynamic things, should use
93
- # attname.
85
+ # * name: The name of the field specified in the model. This is also the
86
+ # attribute and instance __dict__ key for the field's value -- a
87
+ # foreign key stores its raw key under the field name and is
88
+ # reached through its descriptor.
89
+ # * column: The database column for this field. The same as "name",
90
+ # except for ForeignKeys, where the "_id" suffix is appended.
94
91
 
95
92
 
96
93
  def _empty(of_cls: type) -> Empty:
@@ -115,7 +112,6 @@ class Field[T](RegisterLookupMixin):
115
112
  # Set by __init__
116
113
  name: str | None
117
114
  # Set by set_attributes_from_name (called by contribute_to_class)
118
- attname: str
119
115
  column: str
120
116
  concrete: bool
121
117
  # Set by contribute_to_class
@@ -376,8 +372,9 @@ class Field[T](RegisterLookupMixin):
376
372
 
377
373
  def set_attributes_from_name(self, name: str) -> None:
378
374
  self.name = self.name or name
379
- self.attname = self.get_attname()
380
- self.column = self.attname
375
+ # The database column is the field name. A foreign key overrides this
376
+ # method only to append the "_id" suffix to `column`.
377
+ self.column = self.name
381
378
  self.concrete = self.column is not None
382
379
 
383
380
  def contribute_to_class(self, cls: type[Model], name: str) -> None:
@@ -394,7 +391,8 @@ class Field[T](RegisterLookupMixin):
394
391
  # Field is its own descriptor; make sure it is set on the class so
395
392
  # attribute access hits __get__/__set__.
396
393
  if self.column:
397
- setattr(cls, self.attname, self)
394
+ assert self.name is not None
395
+ setattr(cls, self.name, self)
398
396
 
399
397
  # Descriptor protocol implementation
400
398
  @overload
@@ -417,17 +415,25 @@ class Field[T](RegisterLookupMixin):
417
415
 
418
416
  # If field hasn't been contributed to a class yet (e.g., used standalone
419
417
  # as an output_field in aggregates), just return self
420
- if not hasattr(self, "attname"):
418
+ if not hasattr(self, "column"):
421
419
  return self
422
420
 
423
421
  # Instance access - get value from instance dict
422
+ assert self.name is not None
424
423
  data = instance.__dict__
425
- field_name = self.attname
424
+ field_name = self.name
426
425
 
427
- # If value not in dict, lazy load from database
426
+ # If value not in dict, lazy load from database. Hydrate every
427
+ # currently-missing concrete field in one query rather than one query
428
+ # per field accessed -- this is what makes a foreign key partial
429
+ # instance cheap to use beyond its primary key.
428
430
  if field_name not in data:
429
- # Deferred field - load it from the database
430
- instance.refresh_from_db(fields=[field_name])
431
+ missing = [
432
+ f.name
433
+ for f in instance._model_meta.concrete_fields
434
+ if f.name not in data
435
+ ]
436
+ instance.refresh_from_db(fields=missing)
431
437
 
432
438
  return cast(T, data.get(field_name))
433
439
 
@@ -436,10 +442,10 @@ class Field[T](RegisterLookupMixin):
436
442
  Descriptor __set__ for attribute assignment.
437
443
 
438
444
  Validates and converts the value using to_python(), then stores it
439
- in instance.__dict__[attname].
445
+ in instance.__dict__[name].
440
446
  """
441
447
  # Safety check: ensure field has been properly initialized
442
- if not hasattr(self, "attname"):
448
+ if not hasattr(self, "column"):
443
449
  raise AttributeError(
444
450
  f"Field {self.__class__.__name__} has not been initialized properly. "
445
451
  f"The field's contribute_to_class() has not been called yet. "
@@ -452,7 +458,8 @@ class Field[T](RegisterLookupMixin):
452
458
  value = self.to_python(value)
453
459
 
454
460
  # Store in instance dict
455
- instance.__dict__[self.attname] = value
461
+ assert self.name is not None
462
+ instance.__dict__[self.name] = value
456
463
 
457
464
  def __delete__(self, instance: Model) -> None:
458
465
  """
@@ -460,20 +467,18 @@ class Field[T](RegisterLookupMixin):
460
467
 
461
468
  Removes the value from instance.__dict__.
462
469
  """
470
+ assert self.name is not None
463
471
  try:
464
- del instance.__dict__[self.attname]
472
+ del instance.__dict__[self.name]
465
473
  except KeyError:
466
474
  raise AttributeError(
467
- f"{instance.__class__.__name__!r} object has no attribute {self.attname!r}"
475
+ f"{instance.__class__.__name__!r} object has no attribute {self.name!r}"
468
476
  )
469
477
 
470
- def get_attname(self) -> str:
471
- assert self.name is not None # Field name must be set
472
- return self.name
473
-
474
478
  def pre_save(self, model_instance: Model, add: bool) -> T | None:
475
479
  """Return field's value just before saving."""
476
- return getattr(model_instance, self.attname)
480
+ assert self.name is not None
481
+ return getattr(model_instance, self.name)
477
482
 
478
483
  def get_prep_value(self, value: Any) -> Any:
479
484
  """Perform preliminary non-db specific value checks and conversions."""
@@ -530,7 +535,8 @@ class Field[T](RegisterLookupMixin):
530
535
 
531
536
  def value_from_object(self, obj: Model) -> T | None:
532
537
  """Return the value of this field in the given model instance."""
533
- return getattr(obj, self.attname)
538
+ assert self.name is not None
539
+ return getattr(obj, self.name)
534
540
 
535
541
 
536
542
  class ColumnField[T](Field[T]):