plain.postgres 0.103.5__tar.gz → 0.105.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 (214) hide show
  1. plain_postgres-0.103.5/plain/postgres/README.md → plain_postgres-0.105.0/PKG-INFO +30 -0
  2. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/CHANGELOG.md +34 -0
  3. plain_postgres-0.103.5/PKG-INFO → plain_postgres-0.105.0/plain/postgres/README.md +17 -13
  4. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +23 -3
  5. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/base.py +48 -57
  6. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/constraints.py +1 -1
  7. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/base.py +49 -32
  8. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/binary.py +3 -1
  9. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/boolean.py +1 -1
  10. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/duration.py +3 -1
  11. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/encrypted.py +3 -1
  12. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/network.py +1 -1
  13. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/numeric.py +7 -5
  14. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/related.py +38 -58
  15. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/related_descriptors.py +101 -79
  16. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/related_lookups.py +1 -1
  17. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/related_managers.py +22 -32
  18. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/temporal.py +12 -5
  19. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/text.py +4 -4
  20. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/timezones.py +3 -1
  21. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/uuid.py +1 -1
  22. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/meta.py +4 -26
  23. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/query.py +16 -21
  24. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sql/compiler.py +3 -3
  25. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sql/query.py +5 -9
  26. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/types.py +7 -7
  27. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/types.pyi +142 -58
  28. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/pyproject.toml +1 -1
  29. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/constraints.py +2 -2
  30. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/defaults.py +8 -11
  31. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/delete.py +17 -34
  32. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/encrypted.py +4 -4
  33. plain_postgres-0.105.0/tests/app/examples/models/forms.py +31 -0
  34. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/indexes.py +2 -2
  35. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/iteration.py +2 -2
  36. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/mixins.py +3 -5
  37. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/nullability.py +1 -1
  38. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/querysets.py +3 -3
  39. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/relationships.py +4 -6
  40. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/storage_parameters.py +1 -1
  41. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/trees.py +1 -2
  42. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_autodetector_not_null_errors.py +15 -15
  43. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_autodetector_type_change.py +6 -6
  44. plain_postgres-0.105.0/tests/internal/test_fk_characterization.py +156 -0
  45. plain_postgres-0.105.0/tests/public/test_deferred_loading.py +91 -0
  46. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_delete_behaviors.py +8 -9
  47. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_modelform_roundtrip.py +1 -1
  48. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_related.py +197 -2
  49. plain_postgres-0.103.5/tests/app/examples/models/forms.py +0 -35
  50. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/.gitignore +0 -0
  51. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/CLAUDE.md +0 -0
  52. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/LICENSE +0 -0
  53. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/README.md +0 -0
  54. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/__init__.py +0 -0
  55. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/adapters.py +0 -0
  56. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  57. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/aggregates.py +0 -0
  58. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/__init__.py +0 -0
  59. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/converge.py +0 -0
  60. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/core.py +0 -0
  61. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/decorators.py +0 -0
  62. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/diagnose.py +0 -0
  63. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/migrations.py +0 -0
  64. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/schema.py +0 -0
  65. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/cli/sync.py +0 -0
  66. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/config.py +0 -0
  67. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/connection.py +0 -0
  68. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/constants.py +0 -0
  69. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/convergence/__init__.py +0 -0
  70. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/convergence/analysis.py +0 -0
  71. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/convergence/fixes.py +0 -0
  72. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/convergence/planning.py +0 -0
  73. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/database_url.py +0 -0
  74. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/db.py +0 -0
  75. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/ddl.py +0 -0
  76. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/default_settings.py +0 -0
  77. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/deletion.py +0 -0
  78. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/dialect.py +0 -0
  79. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/entrypoints.py +0 -0
  80. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/enums.py +0 -0
  81. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/exceptions.py +0 -0
  82. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/expressions.py +0 -0
  83. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/__init__.py +0 -0
  84. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/json.py +0 -0
  85. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/mixins.py +0 -0
  86. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/primary_key.py +0 -0
  87. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  88. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/fields/reverse_related.py +0 -0
  89. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/forms.py +0 -0
  90. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/__init__.py +0 -0
  91. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/comparison.py +0 -0
  92. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/datetime.py +0 -0
  93. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/math.py +0 -0
  94. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/mixins.py +0 -0
  95. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/random.py +0 -0
  96. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/text.py +0 -0
  97. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/uuid.py +0 -0
  98. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/functions/window.py +0 -0
  99. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/indexes.py +0 -0
  100. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/__init__.py +0 -0
  101. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/__init__.py +0 -0
  102. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  103. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  104. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  105. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/context.py +0 -0
  106. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/helpers.py +0 -0
  107. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/ownership.py +0 -0
  108. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/runner.py +0 -0
  109. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/health/types.py +0 -0
  110. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/introspection/schema.py +0 -0
  111. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/lookups.py +0 -0
  112. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/middleware.py +0 -0
  113. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/__init__.py +0 -0
  114. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/autodetector.py +0 -0
  115. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/exceptions.py +0 -0
  116. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/executor.py +0 -0
  117. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/graph.py +0 -0
  118. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/loader.py +0 -0
  119. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/migration.py +0 -0
  120. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  121. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/operations/base.py +0 -0
  122. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/operations/fields.py +0 -0
  123. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/operations/models.py +0 -0
  124. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/operations/special.py +0 -0
  125. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/optimizer.py +0 -0
  126. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/questioner.py +0 -0
  127. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/recorder.py +0 -0
  128. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/serializer.py +0 -0
  129. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/state.py +0 -0
  130. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/utils.py +0 -0
  131. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/migrations/writer.py +0 -0
  132. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/options.py +0 -0
  133. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/otel.py +0 -0
  134. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/preflight.py +0 -0
  135. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/query_utils.py +0 -0
  136. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/registry.py +0 -0
  137. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/schema.py +0 -0
  138. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sources.py +0 -0
  139. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sql/__init__.py +0 -0
  140. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sql/constants.py +0 -0
  141. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sql/datastructures.py +0 -0
  142. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/sql/where.py +0 -0
  143. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/test/__init__.py +0 -0
  144. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/test/database.py +0 -0
  145. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/test/pytest.py +0 -0
  146. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/transaction.py +0 -0
  147. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/plain/postgres/utils.py +0 -0
  148. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/forms.py +0 -0
  149. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  150. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  151. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  152. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  153. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  154. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  155. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  156. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  157. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  158. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  159. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  160. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  161. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  162. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  163. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  164. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  165. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  166. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  167. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/migrations/__init__.py +0 -0
  168. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/__init__.py +0 -0
  169. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/models/unregistered.py +0 -0
  170. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/urls.py +0 -0
  171. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/examples/views.py +0 -0
  172. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/settings.py +0 -0
  173. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/app/urls.py +0 -0
  174. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/conftest.py +0 -0
  175. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/conftest_convergence.py +0 -0
  176. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_connection_isolation.py +0 -0
  177. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_connection_lifecycle.py +0 -0
  178. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_connection_pool.py +0 -0
  179. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_constraint_violation_error.py +0 -0
  180. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence.py +0 -0
  181. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_constraints.py +0 -0
  182. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_defaults.py +0 -0
  183. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_fk.py +0 -0
  184. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_indexes.py +0 -0
  185. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_nullability.py +0 -0
  186. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  187. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_convergence_timeouts.py +0 -0
  188. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_db_expression_defaults.py +0 -0
  189. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_diagnose.py +0 -0
  190. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_executor_connection_hook.py +0 -0
  191. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_health.py +0 -0
  192. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_introspection.py +0 -0
  193. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_literal_default_persistence.py +0 -0
  194. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_management_connection.py +0 -0
  195. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_migration_executor.py +0 -0
  196. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_no_callable_defaults.py +0 -0
  197. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_otel_metrics.py +0 -0
  198. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  199. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  200. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_schema_normalize_type.py +0 -0
  201. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/internal/test_schema_timeouts.py +0 -0
  202. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_database_url.py +0 -0
  203. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_encrypted_fields.py +0 -0
  204. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_exceptions.py +0 -0
  205. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_field_defaults.py +0 -0
  206. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_functions_uuid.py +0 -0
  207. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_iterator.py +0 -0
  208. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_m2m.py +0 -0
  209. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_manager_assignment.py +0 -0
  210. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_mixins.py +0 -0
  211. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_queryset_repr.py +0 -0
  212. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_random_string_field.py +0 -0
  213. {plain_postgres-0.103.5 → plain_postgres-0.105.0}/tests/public/test_raw_query.py +0 -0
  214. {plain_postgres-0.103.5 → plain_postgres-0.105.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.105.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,39 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.105.0](https://github.com/dropseed/plain/releases/plain-postgres@0.105.0) (2026-05-25)
4
+
5
+ ### What's changed
6
+
7
+ - **Field constructors in `plain.postgres.types` are now typed as parameterized descriptors.** Stubs return `XField[T]` (e.g. `TextField[str]`, `IntegerField[int | None]` when `allow_null=True`) instead of the primitive value. Combined with `Field[T]`'s overloaded `__get__`, class-level access (`User.email`) sees the descriptor and instance access (`user.email`) sees the value — no annotation needed on model declarations. ([229ecdbbfa](https://github.com/dropseed/plain/commit/229ecdbbfa))
8
+ - **`Field.__set__` is now typed as `value: T`** instead of `Any`, so a type checker flags `row.name = 123` on a `TextField` at the call site. Runtime conversion via `to_python()` is unchanged. ([229ecdbbfa](https://github.com/dropseed/plain/commit/229ecdbbfa))
9
+ - **`ForeignKeyField` overloads split by `to=` argument shape.** Passing a model class (`to=Author`) returns `_ForeignKeyDescriptor[T, V]` and accepts bare-PK assignment (`book.author = 5`) via `__set__`. Passing a string forward reference (`to="Author"`) returns the bare value type and requires an LHS annotation so the type can bind. ([229ecdbbfa](https://github.com/dropseed/plain/commit/229ecdbbfa))
10
+ - `JSONField` and `EncryptedJSONField` keep explicit annotations on model declarations — their runtime classes aren't generic, so the stubs return `Any`. ([229ecdbbfa](https://github.com/dropseed/plain/commit/229ecdbbfa))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - **Drop primitive type annotations on model field declarations.** `name: str = types.TextField()` will now fail type checking because the right-hand side is typed `TextField[str]`, not `str`. Change to `name = types.TextField()` — type checkers still infer `str` at instance access through the descriptor's `__get__`. Runtime is unchanged either way.
15
+ - **Keep explicit annotations for `JSONField` / `EncryptedJSONField`** — these still return `Any` from the stubs, so the declared type is what flows through. Example: `parameters: dict[str, Any] | None = types.JSONField(required=False, allow_null=True)`.
16
+
17
+ ## [0.104.0](https://github.com/dropseed/plain/releases/plain-postgres@0.104.0) (2026-05-22)
18
+
19
+ ### What's changed
20
+
21
+ - **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))
22
+ - **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))
23
+ - **`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))
24
+ - **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))
25
+ - **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))
26
+ - 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))
27
+ - `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))
28
+ - 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))
29
+
30
+ ### Upgrade instructions
31
+
32
+ - **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.
33
+ - **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).
34
+ - **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.
35
+ - **`.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.
36
+
3
37
  ## [0.103.5](https://github.com/dropseed/plain/releases/plain-postgres@0.103.5) (2026-05-19)
4
38
 
5
39
  ### What's changed
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.103.5
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):
@@ -7,13 +7,32 @@ paths:
7
7
 
8
8
  ## Field Imports
9
9
 
10
- Import fields via `from plain.postgres import types` and annotate with Python types:
10
+ Import fields via `from plain.postgres import types`. Don't add primitive
11
+ annotations — the field stubs return typed descriptors that resolve to the
12
+ right value type on instance access:
11
13
 
12
14
  ```python
13
15
  from plain.postgres import types
14
16
 
15
- name: str = types.TextField(max_length=100)
16
- car: Car = types.ForeignKeyField("Car", on_delete=postgres.CASCADE)
17
+ name = types.TextField(max_length=100)
18
+ car = types.ForeignKeyField(Car, on_delete=postgres.CASCADE)
19
+ ```
20
+
21
+ For string forward references (`"self"`, `"OtherModel"`), the type checker
22
+ can't infer the target type from the string — annotate explicitly so
23
+ instance access keeps its type:
24
+
25
+ ```python
26
+ parent: TreeNode | None = types.ForeignKeyField("self", on_delete=postgres.CASCADE, allow_null=True)
27
+ ```
28
+
29
+ For `JSONField` and `EncryptedJSONField`, the stub returns `Any` (the
30
+ runtime class isn't generic over its value shape), so annotate explicitly
31
+ to preserve typing:
32
+
33
+ ```python
34
+ parameters: dict[str, Any] | None = types.JSONField(required=False, allow_null=True)
35
+ config: dict | None = types.EncryptedJSONField(required=False, allow_null=True)
17
36
  ```
18
37
 
19
38
  Do NOT import field classes directly from `plain.postgres` or `plain.postgres.fields`.
@@ -49,6 +68,7 @@ Run `uv run plain docs postgres` for full workflow details.
49
68
  Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`).
50
69
 
51
70
  - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N
71
+ - 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
72
  - Use `.annotate(Count(...))` instead of calling `.count()` per row
53
73
  - Fetch all data in the view — templates should never trigger queries
54
74
  - Use `.exists()` not `.count() > 0`, `.count()` not `len(qs)`
@@ -83,7 +83,7 @@ class ModelState:
83
83
 
84
84
  class Model(metaclass=ModelBase):
85
85
  # Every model gets an automatic id field
86
- id: int = types.PrimaryKeyField()
86
+ id = types.PrimaryKeyField()
87
87
 
88
88
  # Descriptors for other model behavior
89
89
  query: QuerySet[Self] = QuerySet()
@@ -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.