plain.postgres 0.118.0__tar.gz → 0.119.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 (302) hide show
  1. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/PKG-INFO +571 -85
  2. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/CHANGELOG.md +29 -0
  3. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/README.md +570 -84
  4. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/__init__.py +17 -3
  5. plain_postgres-0.119.0/plain/postgres/agents/.claude/rules/plain-postgres.md +148 -0
  6. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/base.py +133 -12
  7. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/constants.py +0 -1
  8. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/dialect.py +45 -24
  9. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/expressions.py +106 -3
  10. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/__init__.py +0 -2
  11. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/base.py +242 -20
  12. plain_postgres-0.119.0/plain/postgres/fields/encrypted.py +466 -0
  13. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/related.py +9 -4
  14. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/related_descriptors.py +106 -44
  15. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/related_managers.py +54 -19
  16. plain_postgres-0.119.0/plain/postgres/fields/related_typed.py +167 -0
  17. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/temporal.py +5 -0
  18. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/uuid.py +5 -1
  19. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/meta.py +8 -0
  20. plain_postgres-0.119.0/plain/postgres/middleware.py +45 -0
  21. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/autodetector.py +1 -1
  22. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/options.py +11 -0
  23. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/preflight/models.py +122 -0
  24. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/query.py +1675 -195
  25. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/query_utils.py +102 -1
  26. plain_postgres-0.119.0/plain/postgres/selectable.py +20 -0
  27. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sql/__init__.py +2 -0
  28. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sql/compiler.py +246 -94
  29. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sql/datastructures.py +0 -4
  30. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sql/query.py +31 -19
  31. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/types.py +4 -4
  32. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/types.pyi +67 -16
  33. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/pyproject.toml +1 -1
  34. plain_postgres-0.119.0/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py +34 -0
  35. plain_postgres-0.119.0/tests/app/examples/migrations/0020_stringconditionsexample.py +21 -0
  36. plain_postgres-0.119.0/tests/app/examples/migrations/0021_returningevent.py +19 -0
  37. plain_postgres-0.119.0/tests/app/examples/migrations/0022_upsertitem.py +19 -0
  38. plain_postgres-0.119.0/tests/app/examples/migrations/0023_upsertpair.py +21 -0
  39. plain_postgres-0.119.0/tests/app/examples/migrations/0024_upserttenant_upsertvaluekey_upsertscoped.py +43 -0
  40. plain_postgres-0.119.0/tests/app/examples/migrations/0025_upsertfloatkey.py +20 -0
  41. plain_postgres-0.119.0/tests/app/examples/migrations/0026_upsertdecimalkey.py +20 -0
  42. plain_postgres-0.119.0/tests/app/examples/migrations/0027_upsertstamped.py +34 -0
  43. plain_postgres-0.119.0/tests/app/examples/migrations/0028_aliascollisionexample.py +24 -0
  44. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/__init__.py +5 -0
  45. plain_postgres-0.119.0/tests/app/examples/models/alias_collisions.py +19 -0
  46. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/constraints.py +3 -5
  47. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/defaults.py +12 -13
  48. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/delete.py +36 -41
  49. plain_postgres-0.119.0/tests/app/examples/models/encrypted.py +17 -0
  50. plain_postgres-0.119.0/tests/app/examples/models/forms.py +36 -0
  51. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/indexes.py +3 -5
  52. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/iteration.py +3 -5
  53. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/mixins.py +7 -7
  54. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/nullability.py +2 -4
  55. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/querysets.py +8 -8
  56. plain_postgres-0.119.0/tests/app/examples/models/relationships.py +48 -0
  57. plain_postgres-0.119.0/tests/app/examples/models/returning.py +18 -0
  58. plain_postgres-0.119.0/tests/app/examples/models/shadowing.py +24 -0
  59. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/storage_parameters.py +2 -4
  60. plain_postgres-0.119.0/tests/app/examples/models/string_conditions.py +20 -0
  61. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/trees.py +3 -5
  62. plain_postgres-0.119.0/tests/app/examples/models/upsert.py +160 -0
  63. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/conftest.py +22 -0
  64. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_autodetector_not_null_errors.py +2 -2
  65. plain_postgres-0.119.0/tests/internal/test_conflict_lock_order.py +90 -0
  66. plain_postgres-0.119.0/tests/internal/test_conflict_sort_value.py +134 -0
  67. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_connection_lifecycle.py +62 -0
  68. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_db_expression_defaults.py +35 -12
  69. plain_postgres-0.119.0/tests/internal/test_encrypted_internals.py +85 -0
  70. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_fk_characterization.py +3 -3
  71. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_literal_default_persistence.py +19 -1
  72. plain_postgres-0.119.0/tests/internal/test_lock_mode_sql.py +17 -0
  73. plain_postgres-0.119.0/tests/internal/test_m2m_value_from_object.py +39 -0
  74. plain_postgres-0.119.0/tests/internal/test_meta_related_objects.py +41 -0
  75. plain_postgres-0.119.0/tests/internal/test_random_string_sql.py +42 -0
  76. plain_postgres-0.119.0/tests/internal/test_returning_internals.py +47 -0
  77. plain_postgres-0.119.0/tests/internal/test_returning_order.py +54 -0
  78. plain_postgres-0.119.0/tests/internal/test_stub_runtime_conformance.py +283 -0
  79. plain_postgres-0.119.0/tests/internal/test_typed_construction_preflight.py +82 -0
  80. plain_postgres-0.119.0/tests/internal/test_typed_where_internals.py +101 -0
  81. plain_postgres-0.119.0/tests/public/test_bulk_upsert.py +726 -0
  82. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_delete_behaviors.py +7 -5
  83. plain_postgres-0.119.0/tests/public/test_encrypted_fields.py +302 -0
  84. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_field_defaults.py +1 -1
  85. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_integrity_error_mapping.py +4 -2
  86. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_m2m.py +45 -32
  87. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_manager_assignment.py +4 -2
  88. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_manual_pk.py +2 -2
  89. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_order_by_expressions.py +1 -1
  90. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_random_string_field.py +0 -32
  91. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_related.py +6 -37
  92. plain_postgres-0.119.0/tests/public/test_returning.py +667 -0
  93. plain_postgres-0.119.0/tests/public/test_row_locking.py +312 -0
  94. plain_postgres-0.119.0/tests/public/test_select.py +914 -0
  95. plain_postgres-0.119.0/tests/public/test_typed_where.py +427 -0
  96. plain_postgres-0.119.0/tests/public/test_typed_where_fk.py +412 -0
  97. plain_postgres-0.119.0/tests/public/test_upsert.py +754 -0
  98. plain_postgres-0.119.0/tests/typing/README.md +65 -0
  99. plain_postgres-0.119.0/tests/typing/bulk_writes.py +83 -0
  100. plain_postgres-0.119.0/tests/typing/conditions_encrypted.py +113 -0
  101. plain_postgres-0.119.0/tests/typing/conditions_value_types.py +73 -0
  102. plain_postgres-0.119.0/tests/typing/construction_mixins.py +39 -0
  103. plain_postgres-0.119.0/tests/typing/construction_required_fields.py +37 -0
  104. plain_postgres-0.119.0/tests/typing/construction_unknown_kwargs.py +39 -0
  105. plain_postgres-0.119.0/tests/typing/construction_value_types.py +38 -0
  106. plain_postgres-0.119.0/tests/typing/field_access.py +43 -0
  107. plain_postgres-0.119.0/tests/typing/field_constructors.py +86 -0
  108. plain_postgres-0.119.0/tests/typing/queryset_access.py +46 -0
  109. plain_postgres-0.119.0/tests/typing/relations_foreign_key.py +76 -0
  110. plain_postgres-0.119.0/tests/typing/relations_reverse.py +26 -0
  111. plain_postgres-0.119.0/tests/typing/returning_writes.py +117 -0
  112. plain_postgres-0.119.0/tests/typing/select_ladder.py +116 -0
  113. plain_postgres-0.119.0/tests/typing/select_rows.py +250 -0
  114. plain_postgres-0.119.0/tests/typing/upsert_writes.py +38 -0
  115. plain_postgres-0.118.0/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -102
  116. plain_postgres-0.118.0/plain/postgres/fields/encrypted.py +0 -294
  117. plain_postgres-0.118.0/plain/postgres/middleware.py +0 -37
  118. plain_postgres-0.118.0/tests/app/examples/models/encrypted.py +0 -17
  119. plain_postgres-0.118.0/tests/app/examples/models/forms.py +0 -32
  120. plain_postgres-0.118.0/tests/app/examples/models/relationships.py +0 -43
  121. plain_postgres-0.118.0/tests/public/test_encrypted_fields.py +0 -176
  122. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/.gitignore +0 -0
  123. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/CLAUDE.md +0 -0
  124. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/LICENSE +0 -0
  125. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/README.md +0 -0
  126. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/adapters.py +0 -0
  127. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  128. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/aggregates.py +0 -0
  129. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/__init__.py +0 -0
  130. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/converge.py +0 -0
  131. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/core.py +0 -0
  132. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/decorators.py +0 -0
  133. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/diagnose.py +0 -0
  134. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/migrations.py +0 -0
  135. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/schema.py +0 -0
  136. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/cli/sync.py +0 -0
  137. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/config.py +0 -0
  138. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/connection.py +0 -0
  139. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/constraints.py +0 -0
  140. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/convergence/__init__.py +0 -0
  141. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/convergence/analysis.py +0 -0
  142. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/convergence/corrections.py +0 -0
  143. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/convergence/planning.py +0 -0
  144. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/database_url.py +0 -0
  145. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/databases.py +0 -0
  146. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/db.py +0 -0
  147. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/ddl.py +0 -0
  148. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/default_settings.py +0 -0
  149. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/deletion.py +0 -0
  150. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/entrypoints.py +0 -0
  151. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/enums.py +0 -0
  152. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/exceptions.py +0 -0
  153. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/binary.py +0 -0
  154. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/boolean.py +0 -0
  155. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/duration.py +0 -0
  156. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/json.py +0 -0
  157. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/mixins.py +0 -0
  158. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/network.py +0 -0
  159. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/numeric.py +0 -0
  160. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/primary_key.py +0 -0
  161. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/related_lookups.py +0 -0
  162. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  163. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/reverse_related.py +0 -0
  164. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/text.py +0 -0
  165. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/fields/timezones.py +0 -0
  166. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/forms.py +0 -0
  167. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/__init__.py +0 -0
  168. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/comparison.py +0 -0
  169. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/datetime.py +0 -0
  170. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/math.py +0 -0
  171. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/mixins.py +0 -0
  172. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/random.py +0 -0
  173. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/text.py +0 -0
  174. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/uuid.py +0 -0
  175. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/functions/window.py +0 -0
  176. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/indexes.py +0 -0
  177. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/__init__.py +0 -0
  178. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/__init__.py +0 -0
  179. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  180. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  181. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  182. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/context.py +0 -0
  183. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/helpers.py +0 -0
  184. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/ownership.py +0 -0
  185. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/runner.py +0 -0
  186. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/types.py +0 -0
  187. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/introspection/schema.py +0 -0
  188. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/lookups.py +0 -0
  189. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/__init__.py +0 -0
  190. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/baselines.py +0 -0
  191. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/exceptions.py +0 -0
  192. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/executor.py +0 -0
  193. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/graph.py +0 -0
  194. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/loader.py +0 -0
  195. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/migration.py +0 -0
  196. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  197. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/base.py +0 -0
  198. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/fields.py +0 -0
  199. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/models.py +0 -0
  200. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/special.py +0 -0
  201. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/optimizer.py +0 -0
  202. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/questioner.py +0 -0
  203. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/recorder.py +0 -0
  204. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/reset.py +0 -0
  205. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/serializer.py +0 -0
  206. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/state.py +0 -0
  207. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/utils.py +0 -0
  208. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/migrations/writer.py +0 -0
  209. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/otel.py +0 -0
  210. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/preflight/__init__.py +0 -0
  211. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/preflight/database.py +0 -0
  212. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/preflight/indexes.py +0 -0
  213. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/registry.py +0 -0
  214. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/schema.py +0 -0
  215. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/schema_lock.py +0 -0
  216. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sources.py +0 -0
  217. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sql/constants.py +0 -0
  218. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/sql/where.py +0 -0
  219. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/test/__init__.py +0 -0
  220. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/test/database.py +0 -0
  221. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/test/pytest.py +0 -0
  222. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/transaction.py +0 -0
  223. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/plain/postgres/utils.py +0 -0
  224. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/forms.py +0 -0
  225. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  226. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  227. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  228. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  229. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  230. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  231. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  232. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  233. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  234. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  235. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  236. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  237. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  238. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  239. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  240. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  241. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  242. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  243. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/__init__.py +0 -0
  244. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/models/unregistered.py +0 -0
  245. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/urls.py +0 -0
  246. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/examples/views.py +0 -0
  247. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/settings.py +0 -0
  248. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/app/urls.py +0 -0
  249. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/conftest_convergence.py +0 -0
  250. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/conftest.py +0 -0
  251. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_apply_replan.py +0 -0
  252. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_autodetector_type_change.py +0 -0
  253. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_baselines.py +0 -0
  254. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_connection_isolation.py +0 -0
  255. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_connection_pool.py +0 -0
  256. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_connection_self_heal.py +0 -0
  257. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_constraint_violation_error.py +0 -0
  258. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence.py +0 -0
  259. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_constraints.py +0 -0
  260. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_defaults.py +0 -0
  261. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_fk.py +0 -0
  262. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_indexes.py +0 -0
  263. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_nullability.py +0 -0
  264. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  265. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_timeouts.py +0 -0
  266. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
  267. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_diagnose.py +0 -0
  268. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_executor_connection_hook.py +0 -0
  269. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_health.py +0 -0
  270. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_introspection.py +0 -0
  271. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_management_connection.py +0 -0
  272. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_migration_executor.py +0 -0
  273. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_migrations_reset.py +0 -0
  274. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_no_callable_defaults.py +0 -0
  275. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_otel_metrics.py +0 -0
  276. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  277. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  278. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  279. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_replaces_removed.py +0 -0
  280. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
  281. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_schema_lock.py +0 -0
  282. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_schema_normalize_type.py +0 -0
  283. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_schema_timeouts.py +0 -0
  284. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_shipped_baselines.py +0 -0
  285. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_unresolved_relation_refs.py +0 -0
  286. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/internal/test_writer_operation_options.py +0 -0
  287. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_create_update.py +0 -0
  288. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_database_url.py +0 -0
  289. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_databases.py +0 -0
  290. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_deferred_loading.py +0 -0
  291. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_exceptions.py +0 -0
  292. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_functions_uuid.py +0 -0
  293. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_iterator.py +0 -0
  294. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_mixins.py +0 -0
  295. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_modelform_roundtrip.py +0 -0
  296. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_only_empty_defaults.py +0 -0
  297. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_queryset_ordered.py +0 -0
  298. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_queryset_repr.py +0 -0
  299. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_queryset_slicing.py +0 -0
  300. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_raw_query.py +0 -0
  301. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_read_only_transactions.py +0 -0
  302. {plain_postgres-0.118.0 → plain_postgres-0.119.0}/tests/public/test_related_instance_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plain.postgres
3
- Version: 0.118.0
3
+ Version: 0.119.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -20,6 +20,7 @@ Description-Content-Type: text/markdown
20
20
  - [Middleware](#middleware)
21
21
  - [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
22
22
  - [Querying](#querying)
23
+ - [Returning affected rows](#returning-affected-rows)
23
24
  - [Schema management](#schema-management)
24
25
  - [Syncing](#syncing)
25
26
  - [Structural migrations](#structural-migrations)
@@ -43,21 +44,41 @@ Description-Content-Type: text/markdown
43
44
  from datetime import datetime
44
45
 
45
46
  from plain import postgres
46
- from plain.postgres import types
47
+ from plain.postgres import Field, types
47
48
  from plain.passwords.models import PasswordField
48
49
 
49
50
 
50
51
  @postgres.register_model
51
52
  class User(postgres.Model):
52
- email: str = types.EmailField()
53
- password = PasswordField()
54
- is_admin: bool = types.BooleanField(default=False)
55
- created_at: datetime = types.DateTimeField(create_now=True)
53
+ email: Field[str] = types.EmailField()
54
+ password: Field[str] = PasswordField()
55
+ is_admin: Field[bool] = types.BooleanField(default=False)
56
+ created_at: Field[datetime] = types.DateTimeField(create_now=True)
56
57
 
57
58
  def __str__(self) -> str:
58
59
  return self.email
59
60
  ```
60
61
 
62
+ Annotate each field with `Field[T]` (the value type) — that's what gives the
63
+ model a type-checked constructor: `User(email="a@b.com")` flags wrong value
64
+ types, unknown field names, and missing required fields. A field is optional in
65
+ that constructor only when its definition passes `default=` (this is general,
66
+ not nullable-specific — a `required=False` field with no `default=` is still a
67
+ required constructor arg), so nullable fields use `Field[T | None]` with
68
+ `default=None`. DB-owned fields (`id`, `create_now`, generated values) are
69
+ auto-excluded from the constructor.
70
+
71
+ Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
72
+ your own — are the exception: their stub still types the value, but they are
73
+ always _optional_ in the constructor, even when the column is `NOT NULL`. PEP
74
+ 681 recognizes a field declaration by matching the constructor against a fixed
75
+ list, and that list is baked into `plain.postgres` where a third-party field
76
+ type can't join it, so the checker reads the assignment as a plain default
77
+ value. Wrong value types and unknown field names are still caught; only
78
+ requiredness is lost, and omitting one surfaces as a `NOT NULL` error on insert
79
+ instead. [Sharing fields across models](#sharing-fields-across-models) shows how
80
+ a package that ships a field type can keep it required.
81
+
61
82
  Every model automatically includes an `id` field which serves as the primary
62
83
  key. The name `id` is reserved and can't be used for other fields.
63
84
 
@@ -191,13 +212,143 @@ first_10_users = User.query.all()[:10]
191
212
 
192
213
  For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
193
214
 
215
+ ### Typed conditions with where()
216
+
217
+ `where()` is a typed alternative to `filter()`. Instead of string keyword lookups, you build each condition from a field, so a type checker catches a misspelled field or a wrong value type at the call site:
218
+
219
+ ```python
220
+ from plain import postgres
221
+ from plain.postgres import Field, types
222
+
223
+
224
+ @postgres.register_model
225
+ class User(postgres.Model):
226
+ email: Field[str] = types.EmailField()
227
+ role: Field[str] = types.TextField(max_length=20)
228
+ age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
229
+
230
+
231
+ # Each argument is a condition; multiple arguments are ANDed together.
232
+ admins = User.query.where(
233
+ User.role.equals("admin"),
234
+ User.age.gte(18),
235
+ )
236
+ ```
237
+
238
+ Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `icontains`, `startswith`, and `endswith`. Each returns a `Q`, so you can combine them with `|` and `&` or negate with `~`:
239
+
240
+ ```python
241
+ # Membership, negation, and OR
242
+ User.query.where(User.role.is_in(["admin", "staff"]))
243
+ User.query.where(~User.role.equals("guest"))
244
+ User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
245
+ ```
246
+
247
+ Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
248
+
249
+ ```python
250
+ # Q(author__email="a@example.com")
251
+ Post.query.where(Post.author.email.equals("a@example.com"))
252
+ ```
253
+
254
+ A relation is a path to traverse, not a field, so it carries no conditions of its own. To match on the relation itself, traverse to the key it points at — that's the typed spelling of `filter(author=author)`, and it compiles to the same SQL:
255
+
256
+ ```python
257
+ Post.query.where(Post.author.id.equals(author.id))
258
+ Post.query.where(Post.author.id.is_in([a.id for a in authors]))
259
+ Post.query.where(Post.author.id.is_null()) # nullable relation
260
+ ```
261
+
262
+ `Post.author.equals(author)` raises `AttributeError` naming this spelling (an `AttributeError`, so `hasattr` and `getattr(..., default)` keep behaving). It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects.
263
+
264
+ Traversal starts from a **forward foreign key**. Once inside one, every relation you pass through is another hop, many-to-many included — `WidgetTag.widget.tags.name.equals("metal")` builds `Q(widget__tags__name="metal")` — and the same rule applies to the relation itself: `WidgetTag.widget.tags.equals(tag)` points you at `WidgetTag.widget.tags.id.equals(tag.id)`.
265
+
266
+ A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no traversal wiring, and it is typed `ManyToManyManager[Tag]`, so it could not be typed as one either. Use the string path there — `Widget.query.filter(tags__name="metal")`. Reverse relations aren't traversable for the same reason (a reverse accessor is a `ClassVar`, so there is nothing for the related model to offer the checker), and the error says so.
267
+
268
+ A traversed field _is_ the related field, carrying the relation path as its name — so it offers exactly the conditions that field offers, including an encrypted field's refusals.
269
+
270
+ **A condition belongs to the model whose field built it.** `Order.query.where(User.email.equals("x"))` raises `TypeError` naming both models. A type checker can't catch this — `Field[str]` is `Field[str]` whichever model declared it — and without the check the lookup name `"email"` just resolves against `Order`, which is silently the wrong column when both models have one. A traversed condition belongs to the model the traversal _started_ from, so `Order.query.where(Order.user.email.equals("x"))` is `Order`'s, not `User`'s. A hand-written `Q(email="x")` names no model and isn't checked — it's `filter()`'s untyped spelling and behaves like it.
271
+
272
+ [Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`.
273
+
274
+ ### Selecting columns with select()
275
+
276
+ `select()` pulls back specific columns as typed rows instead of model instances. You pass typed field references, and a type checker knows the exact shape of each row:
277
+
278
+ ```python
279
+ from plain.postgres import Field, types
280
+
281
+
282
+ @postgres.register_model
283
+ class User(postgres.Model):
284
+ email: Field[str] = types.EmailField()
285
+ age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
286
+
287
+
288
+ # list-like of tuple[str, int | None], precisely typed
289
+ rows = User.query.where(User.age.gte(18)).select(User.email, User.age)
290
+ for email, age in rows:
291
+ ...
292
+ ```
293
+
294
+ There are three modes:
295
+
296
+ - **Tuples** (default) — one tuple per row, typed per column: `select(User.email, User.age)` yields `tuple[str, int | None]`.
297
+ - **Flat scalars** — a single column unwrapped, with `flat=True`: `select(User.email, flat=True)` yields `str`. `flat=True` accepts exactly one column.
298
+ - **Dataclasses** — map each column onto a dataclass with `result_type=`: `select(User.email, User.age, result_type=UserStats)` yields `UserStats`. Columns map to dataclass fields **positionally**, so the selection order must match the dataclass field order, and each selected field's name must match the dataclass field at the same position.
299
+
300
+ ```python
301
+ from dataclasses import dataclass
302
+
303
+
304
+ @dataclass
305
+ class UserStats:
306
+ email: str
307
+ age: int | None
308
+
309
+
310
+ stats = User.query.select(User.email, User.age, result_type=UserStats)
311
+ ```
312
+
313
+ The return value is a [`RowQuerySet`](./query.py#RowQuerySet) — that is the name to reach for when you need to annotate one:
314
+
315
+ ```python
316
+ from plain.postgres import RowQuerySet
317
+
318
+
319
+ def adults() -> RowQuerySet[tuple[str, int | None]]:
320
+ return User.query.where(User.age.gte(18)).select(User.email, User.age)
321
+ ```
322
+
323
+ You can select expression columns too — `select(User.id, Sum("amount"))`, or an `F()` — but an expression column types as `Any` (its output type isn't tracked yet). The fields around it stay precise, so `select(User.id, Sum("amount"))` types as `tuple[int, Any]`.
324
+
325
+ Per-column typing runs to **ten columns**. An eleventh is still selected and still returns rows, but the row type degrades to `tuple[Any, ...]` — reach for `result_type=` when a row is that wide.
326
+
327
+ `select()` goes last in a chain: `annotate()` must come before it, because an annotation appends a column and would change the row shape out from under the type `select()` declared. `annotate()` after `select()` raises `TypeError` saying so. `prefetch_related()` is refused in both orders — a prefetch attaches related objects to a model instance's attributes, and a row has nowhere to put them; select the columns you need from the related model instead.
328
+
329
+ Re-selecting replaces the **column list**, not the joins: an expression that reached through a relation (`select(Upper("tags__name"))`) leaves its join in place, so a later `select(Widget.name)` still returns one row per joined row — and `count()`/`exists()` count those. This is `annotate(...)` followed by `values_list(...)` behaving as it always has; trimming joins no queryset needs any more is out of scope here.
330
+
331
+ `distinct()` with an `order_by()` on a column you didn't select returns duplicates: the ordering column has to go into the `SELECT` list for Postgres to sort by it, so `SELECT DISTINCT` deduplicates on that column too. Order by something you selected, or drop the ordering. This is `values_list()`'s behavior as well, not new to `select()`.
332
+
333
+ Columns annotated `Field[Any]` are rejected by `select()`, because `Any` satisfies the model-valued `__get__` overload and class access resolves as `type[Any]` rather than a field. That's the same reason the field-annotation guidance says never to annotate a field `Field[Any]` — use the concrete type, or `Field[object]` when the column really does hold arbitrary JSON, which `select()` types as `object`.
334
+
335
+ **`select()` returns rows, not partial model instances.** This is deliberate: a model instance with only some columns loaded is a type-level lie — the type checker thinks every field is present, so touching an unselected column looks fine but fails or fires a hidden query at runtime. Honest tuples/dataclasses keep the types truthful. As a result, iteration, `first()`, `get()`, `iterator()`, and slicing all return rows, and anything that would read or write model rows, or change the selected columns — `update()`, `delete()`, `get_or_create()`, `values()`, `values_list()`, `annotate()`, `prefetch_related()` — raises `TypeError`. `update()` and `delete()` refuse a queryset in row mode however it got there, `values()` and `values_list()` included.
336
+
337
+ `select()` takes typed references only — a bare string like `select("email")` raises `TypeError` (use `User.email`).
338
+
339
+ **A column belongs to the model whose field built it**, the same as [a condition does](#querying-with-typed-conditions): `Order.query.select(User.email)` raises `TypeError` naming both models. A type checker can't catch it — `Field[str]` is `Field[str]` whichever model declared it — and without the check the name `"email"` just resolves against `Order`, silently the wrong column when both models have one. Expressions are unaffected: `F("email")` and `Upper("email")` take a string resolved against whatever query they land in, like `filter()`'s kwargs.
340
+
341
+ **Relations are not selectable yet.** `select(Post.author)` (the relation) and `select(Post.author.city)` (a column through it) both raise `TypeError`, and so does `select(Post.author.id)` — the foreign key column itself. The reason is nullability: a column reached through a relation arrives over a join, so a nullable relation yields `None` where the traversed field's type says it can't. Until `select()` can express that, `values_list("author__id", flat=True)` is the spelling, and the error message names it.
342
+
343
+ **`select()` hands back a plain `RowQuerySet`, not your custom QuerySet subclass.** Chain your own methods before `select()`, not after — `User.query.active().select(...)` works, `User.query.select(...).active()` raises `AttributeError`.
344
+
194
345
  ### Custom QuerySets
195
346
 
196
- You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. Define a custom QuerySet and assign it to your model's `query` attribute:
347
+ You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. Define a custom QuerySet and assign it to your model's `query` attribute as a `ClassVar` (so it isn't treated as a constructor field):
197
348
 
198
349
  ```python
199
- from typing import Self
200
- from plain.postgres import types
350
+ from typing import ClassVar, Self
351
+ from plain.postgres import Field, types
201
352
 
202
353
 
203
354
  class PublishedQuerySet(postgres.QuerySet["Article"]):
@@ -210,10 +361,10 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
210
361
 
211
362
  @postgres.register_model
212
363
  class Article(postgres.Model):
213
- title: str = types.TextField(max_length=200)
214
- status: str = types.TextField(max_length=20)
364
+ title: Field[str] = types.TextField(max_length=200)
365
+ status: Field[str] = types.TextField(max_length=20)
215
366
 
216
- query = PublishedQuerySet()
367
+ query: ClassVar[PublishedQuerySet] = PublishedQuerySet()
217
368
 
218
369
 
219
370
  # Usage - all methods available on Article.query
@@ -233,24 +384,11 @@ special_qs = SpecialQuerySet.from_model(Article)
233
384
 
234
385
  ### Typing QuerySets
235
386
 
236
- For better type checking of query results, you can explicitly type the `query` attribute:
237
-
238
- ```python
239
- from __future__ import annotations
240
-
241
- from plain import postgres
242
- from plain.postgres import types
243
-
244
-
245
- @postgres.register_model
246
- class User(postgres.Model):
247
- email: str = types.EmailField()
248
- is_admin: bool = types.BooleanField(default=False)
249
-
250
- query: postgres.QuerySet[User] = postgres.QuerySet()
251
- ```
252
-
253
- With this annotation, type checkers will know that `User.query.get()` returns a `User` instance and `User.query.filter()` returns `QuerySet[User]`. This is optional but improves IDE autocomplete and type checking.
387
+ `Model.query` is typed automatically — `User.query.get()` returns a `User` and
388
+ `User.query.filter()` returns `QuerySet[User]` with no extra annotation. Don't
389
+ redeclare `query` just to type it; the base provides `QuerySet[Self]`. Declare
390
+ `query` only when attaching a **custom** QuerySet, and then as a `ClassVar`
391
+ (see [Custom QuerySets](#custom-querysets) above).
254
392
 
255
393
  ### Raw SQL
256
394
 
@@ -410,6 +548,176 @@ for name in names:
410
548
  Tag.query.bulk_create([Tag(name=name) for name in names])
411
549
  ```
412
550
 
551
+ `bulk_create` is insert-only. To insert new rows and update the ones that
552
+ already exist in a single statement, use `bulk_upsert` (below).
553
+
554
+ #### Use `bulk_upsert` to insert-or-update in one statement
555
+
556
+ `bulk_upsert(objs, *, update_fields, unique_fields, batch_size=None)` issues one
557
+ `INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET ... RETURNING` per batch.
558
+ Rows that don't exist yet are inserted; rows that collide on `unique_fields` have
559
+ their `update_fields` overwritten. You get back the objects you passed in, in the
560
+ order you passed them (a new list — `objs` itself is never reordered), each with
561
+ its DB-generated fields (primary key, DB defaults) populated.
562
+
563
+ ```python
564
+ # Insert new items, refresh `value`/`expires_at` on any existing key.
565
+ CachedItem.query.bulk_upsert(
566
+ [CachedItem(key=k, value=v, expires_at=exp) for k, v in items],
567
+ update_fields=[CachedItem.value, CachedItem.expires_at],
568
+ unique_fields=[CachedItem.key],
569
+ )
570
+ ```
571
+
572
+ - `update_fields` and `unique_fields` take field references (`Model.field`), not
573
+ strings. A foreign key is named by the relation itself — `Model.tenant`, which
574
+ resolves to the `tenant_id` column. (This is the one write API that takes
575
+ `Model.fk`. `returning()` refuses it, because there it would be ambiguous with
576
+ asking for the whole related object; here a column list can only mean the
577
+ column.)
578
+ - `unique_fields` must name the **primary key** or a `UniqueConstraint` declared
579
+ on the model (no condition, no expressions) — this is the conflict target. A
580
+ unique `Index` is not enough; declare a `UniqueConstraint`.
581
+ - `update_fields` must be concrete, non-primary-key, must not name the same
582
+ column twice (Postgres assigns each column once per statement), and must not
583
+ overlap `unique_fields`. A column the database fills in (`create_now`,
584
+ `generate=True`, `RandomStringField`) can't be named either — the update would
585
+ overwrite the stored value with a freshly evaluated default.
586
+ - Every object must have a non-null value for every unique field. `NULL` never
587
+ conflicts in Postgres, so it can't be upserted. A database-generated column
588
+ (`create_now`, `generate=True`, `RandomStringField`) can't be a unique field
589
+ either — your objects never hold its value, so it could never conflict. Nor
590
+ can an `update_now=True` column, which is stamped again on every write.
591
+ - **Two objects with the same unique key in one batch raise `ValueError`.**
592
+ Postgres won't touch a row twice in one statement. Split across batches it's
593
+ allowed — the first inserts, the second updates, and the later write wins.
594
+ - **`update_now=True` columns are refreshed on a conflict automatically.** You
595
+ don't name them in `update_fields`; a row that gets updated gets a fresh
596
+ stamp, and the object handed back carries the same one.
597
+ - **An `id` you set is kept on the insert path; on a conflict the stored row
598
+ wins.** A new row is written with the `id` you gave it. A conflicting one
599
+ already has an `id`, and that is the one hydrated back onto your object — the
600
+ row in the table is the truth. An `id` that collides with a _different_ row
601
+ raises `psycopg.errors.UniqueViolation`, like any set-based write.
602
+ - Every object is sorted by its conflict key before anything is sent, so
603
+ concurrent `bulk_upsert` calls over overlapping keys lock rows in the same
604
+ order and can't deadlock each other. Returned rows are mapped onto the objects
605
+ by position, exactly as `bulk_create` does.
606
+ - Like `bulk_create`, the write is against the table: a filter on the queryset
607
+ you call it from doesn't narrow or exclude anything.
608
+
609
+ For a single row, reach for `upsert` (below) instead — it returns the object and
610
+ a `created` flag rather than a list.
611
+
612
+ #### Use `upsert` for a single insert-or-update
613
+
614
+ `upsert(*, unique_fields, defaults=None, create_defaults=None, conflict_defaults=None, **kwargs)`
615
+ is the single-row counterpart to `bulk_upsert`. It runs one
616
+ `INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET ... RETURNING` statement and
617
+ returns `(obj, created)` — `created` is `True` when a new row was inserted,
618
+ `False` when the conflicting row was updated. The object is hydrated from the
619
+ post-write row, so there's no second query.
620
+
621
+ ```python
622
+ # Insert the flag, or refresh used_at on the existing one.
623
+ flag, created = Flag.query.upsert(
624
+ name="beta-dashboard",
625
+ defaults={"used_at": timezone.now()},
626
+ unique_fields=[Flag.name],
627
+ )
628
+ ```
629
+
630
+ `unique_fields` takes field references (`Model.field`), like `bulk_upsert`. The
631
+ value sources below stay string-keyed — they follow the `kwargs` idiom.
632
+
633
+ Value sources, lowest precedence first — where the same key appears in two of
634
+ them, `create_defaults` loses to `defaults`, which loses to `**kwargs`:
635
+
636
+ - `create_defaults` is applied on **insert only** — extras that must not change
637
+ when the row already exists.
638
+ - `defaults` and `**kwargs` are applied on **both** insert and conflict-update.
639
+ `kwargs` carries the identifying values (including the unique fields).
640
+ - `conflict_defaults` applies to the `DO UPDATE SET` **only** — it never changes
641
+ the inserted row. A value can be a plain value or an expression, so
642
+ `{"count": F("count") + 1}` is an atomic counter that reads the existing row:
643
+
644
+ ```python
645
+ view, created = PageView.query.upsert(
646
+ path="/home",
647
+ conflict_defaults={"count": F("count") + 1},
648
+ unique_fields=[PageView.path],
649
+ )
650
+ ```
651
+
652
+ Two expressions reach two different rows inside a conflict update. `F("count")`
653
+ reads the row already **stored**; `Excluded("count")` reads the row the INSERT
654
+ **proposed**, compiling to `EXCLUDED."count"`. Combine them to accumulate the
655
+ incoming value instead of overwriting it — the whole statement is one atomic
656
+ `UPDATE`, so concurrent callers each add their own delta:
657
+
658
+ ```python
659
+ from plain.postgres import Excluded, F
660
+
661
+ # Add this batch's 7 views to whatever is already stored.
662
+ view, created = PageView.query.upsert(
663
+ path="/home",
664
+ count=7,
665
+ conflict_defaults={"count": F("count") + Excluded("count")},
666
+ unique_fields=[PageView.path],
667
+ )
668
+ ```
669
+
670
+ `Excluded()` is only meaningful while the conflict update's assignments are
671
+ being built. Anywhere else raises a `FieldError` — including as an inserted
672
+ value in `kwargs`/`defaults`/`create_defaults` of the very same call, where it
673
+ would be naming the row being written, and in `filter()`, `update()` or
674
+ `annotate()`.
675
+
676
+ On conflict the `SET` clause covers:
677
+
678
+ - every non-unique, non-PK column from `kwargs`/`defaults`, each taking the value
679
+ the INSERT proposed;
680
+ - every `DateTimeField(update_now=True)` column, whose fresh `pre_save()`
681
+ timestamp rides along in the INSERT and would otherwise go stale (a
682
+ `create_now`-only column is _not_ in the `SET`, so it keeps its original
683
+ value);
684
+ - every column `conflict_defaults` names — replacing the proposed value when the
685
+ column is already in the `SET`, adding it when it isn't.
686
+
687
+ Columns nobody wrote are left alone, and `create_defaults` never take part in the
688
+ update. Naming a **database-owned** column (`create_now`, `generate=True`,
689
+ `RandomStringField`) in `kwargs`/`defaults` is an error rather than a silent
690
+ reset: `EXCLUDED` carries a freshly evaluated default, so the conflict update
691
+ would overwrite the stored creation timestamp every time. A column that's also
692
+ `update_now` is exempt — refreshing it is the point. Unlike `bulk_upsert`, `upsert` derives its `SET` columns rather than
693
+ taking them, so a `conflict_defaults` key may not name a unique field — that's
694
+ the conflict target.
695
+
696
+ Every value source resolves callables, and every key must name a **column** — a
697
+ settable property is refused, since the `SET` clause is derived from columns and
698
+ a property could only ever be written on the insert half.
699
+
700
+ `unique_fields` must name a `UniqueConstraint` declared on the model (no
701
+ condition, no expressions) and every unique field must be non-null. It can't be
702
+ the primary key: Postgres generates the identity value, so a caller has nothing
703
+ to conflict on.
704
+
705
+ Three things to keep in mind:
706
+
707
+ - **`kwargs` are values, not filters.** A keyword that isn't part of the conflict
708
+ key doesn't narrow which row is matched — `unique_fields` alone decides that —
709
+ it's just another column written to whichever row conflicts.
710
+ - **The queryset's filters don't scope it either.** `qs.filter(...).upsert(...)`
711
+ writes the conflicting row whether or not it matches the filter — the conflict
712
+ constraint decides which row is touched. It isn't refused because the
713
+ related-manager wrappers call through a filtered queryset. To scope an upsert,
714
+ fold the scoping column into `unique_fields` (and into the constraint), or do a
715
+ locked read and write instead.
716
+ - **The merged row isn't validated**, and a constraint violation surfaces as a raw
717
+ `psycopg.IntegrityError`, not a `ValidationError`. `upsert` looks single-row
718
+ like `create()`, but it's a set-based write like `bulk_upsert` (see
719
+ [Validation](#validation)).
720
+
413
721
  #### Use queryset `.update()` / `.delete()` for mass operations
414
722
 
415
723
  ```python
@@ -450,6 +758,44 @@ for row in HugeTable.query.iterator(chunk_size=2000):
450
758
  process(row)
451
759
  ```
452
760
 
761
+ ## Returning affected rows
762
+
763
+ `QuerySet.update()` and `QuerySet.delete()` return an `int` rowcount. Chain `returning()` before the write to get the affected rows back instead — Postgres' `RETURNING` clause fetches them in the same statement, so there's no second query.
764
+
765
+ ```python
766
+ # No arguments: rows come back as model instances.
767
+ running = Job.query.filter(status="pending").returning().update(status="running")
768
+ for job in running:
769
+ print(job.id, job.status) # reflects the post-update values
770
+
771
+ # Field references: rows come back as dicts of just those columns.
772
+ deleted = (
773
+ Event.query.filter(created_at__lt=cutoff)
774
+ .returning(Event.id, Event.payload)
775
+ .delete()
776
+ )
777
+ for row in deleted:
778
+ print(row["id"], row["payload"]) # the rows as they were deleted
779
+ ```
780
+
781
+ - **`returning()`** returns full model instances. For `update()` they hold the new values and stay live. For `delete()` they are **read-only snapshots**: every value is there to read, the id included, but the row is gone, so `create()`, `update()` and `delete()` on them raise.
782
+ - That is the opposite of `Model.delete()`, which clears the instance's id and leaves it re-creatable — that instance is a row you still hold and may want to put back, while a snapshot is a record of one that was removed, and its id is the point.
783
+ - **`returning(Model.field, ...)`** returns a list of dicts with only those columns. Pass field references (`Model.field`), not strings; a many-to-many field or one from another model raises an error at the `returning()` call.
784
+ - **A foreign key can't be named here.** At class level `Model.fk` is the relation — that is what lets `where()` traverse it, as in `Child.parent.name.equals(...)` — not its column, so `returning(Child.parent)` raises `FieldError`. Foreign key columns come back through no-argument `returning()`, which hands you whole instances.
785
+ - Without `returning()`, `update()`/`delete()` return an `int` as before.
786
+ - `returning()` only applies to `update()` and `delete()`. Any other write on the same queryset — `create()`, `bulk_create()`, `bulk_upsert()`, `bulk_update()`, `get_or_create()`, `upsert()` — raises `TypeError` rather than quietly dropping it.
787
+ - `returning()` keeps the queryset's own class, so a custom `QuerySet` and its methods survive it. Chain your own methods before `returning()` — a type checker sees the returning shape after it, not your subclass.
788
+
789
+ A row lock belongs on the read side of the write, and it composes in either order. The write then needs an open `transaction.atomic()`, and is emitted as a locking sub-select so the lock has somewhere to live — see [Locking a set-based write](#locking-a-set-based-write).
790
+
791
+ `returning()` is inert for reads. It describes what the next `update()` or `delete()` hands back, so iterating, `count()`, `first()` and `values()` on the same queryset behave exactly as they would without it — which is what lets you inspect a chain before writing it.
792
+
793
+ The values you get back are whatever the statement wrote, exactly as Postgres holds them. A set-based `update()` doesn't run Python-side field hooks, so an `update_now=True` timestamp comes back unchanged unless the `update()` set it.
794
+
795
+ `RETURNING` only reports rows of the statement's own target table. Rows removed by a cascading `ON DELETE` are never included — a `delete()` with `returning()` gives you the parent rows you deleted, not the children Postgres cascaded.
796
+
797
+ Every affected row is fetched and built into memory at once, so `returning()` belongs on writes you've already bounded by a filter. For a write that spans a whole table, take the rowcount and page through the rows separately.
798
+
453
799
  ## Transactions
454
800
 
455
801
  By default, each query runs in its own implicit transaction and is committed immediately (autocommit mode). When you need multiple queries to succeed or fail together — like creating a user and their profile — wrap them in an explicit transaction.
@@ -509,6 +855,81 @@ with read_only():
509
855
  User.query.count() # still works — outer txn is healthy
510
856
  ```
511
857
 
858
+ ### Row-level locking
859
+
860
+ Lock the rows a query selects so concurrent transactions can't change them until yours commits. Postgres offers four lock strengths, from strongest to weakest, each with its own QuerySet method:
861
+
862
+ ```python
863
+ with transaction.atomic():
864
+ account = Account.query.for_update().get(id=1) # FOR UPDATE
865
+ account.balance -= 100
866
+ account.update(fields=["balance"])
867
+ ```
868
+
869
+ | Method | SQL clause | Use it when |
870
+ | --------------------- | ------------------- | -------------------------------------------------------------------------- |
871
+ | `for_update()` | `FOR UPDATE` | You intend to update or delete the row. |
872
+ | `for_no_key_update()` | `FOR NO KEY UPDATE` | Same, but you won't touch the primary key — lets key-share locks proceed. |
873
+ | `for_share()` | `FOR SHARE` | You need the row to stay put while you read it, but others may also share. |
874
+ | `for_key_share()` | `FOR KEY SHARE` | Weakest — only blocks changes to the row's key. |
875
+
876
+ Locking requires an open transaction. The method itself just builds the queryset — evaluating a locked queryset outside `transaction.atomic()` is what raises `TransactionManagementError`.
877
+
878
+ All four accept the same options:
879
+
880
+ - `nowait=True` — raise instead of waiting if a row is already locked.
881
+ - `skip_locked=True` — skip already-locked rows instead of waiting (can't be combined with `nowait`).
882
+ - `of=("self", "related")` — lock only the named tables in a join rather than every selected row.
883
+
884
+ ```python
885
+ # Claim the next available job without blocking on rows another worker holds
886
+ job = Job.query.for_update(skip_locked=True).filter(status="pending").first()
887
+ ```
888
+
889
+ Chaining more than one lock method keeps only the last one, options included.
890
+
891
+ Postgres can only lock rows that map one-to-one onto table rows, so a lock can't be combined with `distinct()`, an aggregate annotation, or a window annotation. Either order raises `psycopg.NotSupportedError` when the queryset is built, naming the lock method:
892
+
893
+ ```python
894
+ Widget.query.distinct().for_update() # NotSupportedError
895
+ Widget.query.for_update().distinct() # same error, either way round
896
+ ```
897
+
898
+ `count()` and `aggregate()` are the exception — they compile to an aggregate query of their own, so they drop the lock rather than reject it.
899
+
900
+ #### Locking a set-based write
901
+
902
+ A lock also applies to `update()` and `delete()` on the same queryset. Neither statement takes a locking clause of its own, so the write is emitted as a locking sub-select:
903
+
904
+ ```python
905
+ # Claim *all* pending rows matching the filter, in one statement
906
+ with transaction.atomic():
907
+ claimed = (
908
+ Job.query.filter(status="pending")
909
+ .for_update(skip_locked=True)
910
+ .returning()
911
+ .update(status="running")
912
+ )
913
+ ```
914
+
915
+ ```sql
916
+ UPDATE "jobs" SET "status" = 'running'
917
+ WHERE "id" IN (
918
+ SELECT U0."id" FROM "jobs" U0 WHERE U0."status" = 'pending' FOR UPDATE OF U0 SKIP LOCKED
919
+ )
920
+ RETURNING ...
921
+ ```
922
+
923
+ It is still one statement. A second worker running the same write skips the rows the first one holds instead of blocking on them, so each row is claimed once.
924
+
925
+ Note what this is and isn't: it takes **every** row the filter matches, so it suits draining a batch, not handing one unit of work to one worker. A bounded claim would need a sliced write (`[:1]`), and `update()`/`delete()` reject a sliced queryset — so for a per-worker claim, take one row with the locked read above (`for_update(skip_locked=True)` + `first()`) and write it separately.
926
+
927
+ The `transaction.atomic()` is required, same as for a locked read: a locked write outside a transaction raises `TransactionManagementError`. Nothing else honors the lock — without a transaction there is nothing for it to be held until.
928
+
929
+ **A locked write locks only the target table's rows.** When the filter spans a relation, the sub-select joins the other tables to look values up, and a bare `FOR UPDATE` would lock a row in each of them — so a write whose filter reads a parent would wait on (or, with `skip_locked=True`, silently skip) rows it never touches. The clause is emitted as `FOR UPDATE OF <target>` so that can't happen. To lock related rows as well, take them with a separate locked read.
930
+
931
+ That is also why `of=` can only name `"self"` on a write: the sub-select reads one column — this table's id — so a related name has nothing to point at, and `update()`/`delete()` raise `TypeError` rather than let it fail deeper down. Dropping `of=` is the same thing; the write locks its own rows either way.
932
+
512
933
  ## Schema management
513
934
 
514
935
  Schema changes fall into three categories, each with a different author and apply model:
@@ -743,9 +1164,9 @@ Convergence compares the indexes, constraints, foreign keys, nullability, and [s
743
1164
  ```python
744
1165
  @postgres.register_model
745
1166
  class User(postgres.Model):
746
- email: str = types.EmailField()
747
- username: str = types.TextField(max_length=150)
748
- age: int = types.IntegerField()
1167
+ email: Field[str] = types.EmailField()
1168
+ username: Field[str] = types.TextField(max_length=150)
1169
+ age: Field[int] = types.IntegerField()
749
1170
 
750
1171
  model_options = postgres.Options(
751
1172
  indexes=[
@@ -854,24 +1275,24 @@ from decimal import Decimal
854
1275
  from datetime import datetime
855
1276
 
856
1277
  from plain import postgres
857
- from plain.postgres import types
1278
+ from plain.postgres import Field, types
858
1279
 
859
1280
 
860
1281
  class Product(postgres.Model):
861
1282
  # Text fields
862
- name: str = types.TextField(max_length=200)
863
- description: str = types.TextField()
1283
+ name: Field[str] = types.TextField(max_length=200)
1284
+ description: Field[str] = types.TextField()
864
1285
 
865
1286
  # Numeric fields
866
- price: Decimal = types.DecimalField(max_digits=10, decimal_places=2)
867
- quantity: int = types.IntegerField(default=0)
1287
+ price: Field[Decimal] = types.DecimalField(max_digits=10, decimal_places=2)
1288
+ quantity: Field[int] = types.IntegerField(default=0)
868
1289
 
869
1290
  # Boolean fields
870
- is_active: bool = types.BooleanField(default=True)
1291
+ is_active: Field[bool] = types.BooleanField(default=True)
871
1292
 
872
1293
  # Date and time fields
873
- created_at: datetime = types.DateTimeField(create_now=True)
874
- updated_at: datetime = types.DateTimeField(update_now=True)
1294
+ created_at: Field[datetime] = types.DateTimeField(create_now=True)
1295
+ updated_at: Field[datetime] = types.DateTimeField(update_now=True)
875
1296
  ```
876
1297
 
877
1298
  **Text fields:**
@@ -914,43 +1335,81 @@ See [Encrypted fields](#encrypted-fields) for details.
914
1335
 
915
1336
  For relationship fields, see [Relationships](#relationships).
916
1337
 
917
- For nullable fields, use `| None` in the annotation:
1338
+ For nullable fields, use `| None` in the annotation and `default=None` to make
1339
+ the field optional in the constructor:
918
1340
 
919
1341
  ```python
920
- published_at: datetime | None = types.DateTimeField(allow_null=True, required=False)
1342
+ published_at: Field[datetime | None] = types.DateTimeField(
1343
+ allow_null=True, required=False, default=None
1344
+ )
921
1345
  ```
922
1346
 
923
1347
  ### Sharing fields across models
924
1348
 
925
- To share common fields across multiple models, use Python classes as mixins. The final, registered model must inherit directly from `postgres.Model` and the mixins should not.
1349
+ To share common fields across multiple models, use Python classes as mixins. A mixin that declares fields must inherit `postgres.ModelMixin`, and the final, registered model must inherit directly from `postgres.Model` (the mixins must not).
926
1350
 
927
1351
  ```python
928
1352
  from datetime import datetime
929
1353
 
930
1354
  from plain import postgres
931
- from plain.postgres import types
1355
+ from plain.postgres import Field, ModelMixin, types
932
1356
 
933
1357
 
934
1358
  # Regular Python class for shared fields
935
- class TimestampedMixin:
936
- created_at: datetime = types.DateTimeField(create_now=True)
937
- updated_at: datetime = types.DateTimeField(update_now=True)
1359
+ class TimestampedMixin(ModelMixin):
1360
+ created_at: Field[datetime] = types.DateTimeField(create_now=True)
1361
+ updated_at: Field[datetime] = types.DateTimeField(update_now=True)
1362
+ source: Field[str] = types.TextField(max_length=50, required=False, default="")
938
1363
 
939
1364
 
940
1365
  # Models inherit from the mixin AND postgres.Model
941
1366
  @postgres.register_model
942
1367
  class User(TimestampedMixin, postgres.Model):
943
- email: str = types.EmailField()
944
- password = PasswordField()
945
- is_admin: bool = types.BooleanField(default=False)
1368
+ email: Field[str] = types.EmailField()
1369
+ password: Field[str] = PasswordField()
1370
+ is_admin: Field[bool] = types.BooleanField(default=False)
946
1371
 
947
1372
 
948
1373
  @postgres.register_model
949
1374
  class Note(TimestampedMixin, postgres.Model):
950
- content: str = types.TextField(max_length=1024)
951
- liked: bool = types.BooleanField(default=False)
1375
+ content: Field[str] = types.TextField(max_length=1024)
1376
+ liked: Field[bool] = types.BooleanField(default=False)
1377
+ ```
1378
+
1379
+ `ModelMixin` is what puts the mixin's fields into each model's typed
1380
+ constructor. The runtime collects fields off the whole MRO either way, but a
1381
+ mixin inheriting nothing isn't visible to PEP 681, so the checker would reject
1382
+ `Note(source="import")` on code that runs fine. `ModelMixin` carries no runtime
1383
+ behavior — it's the same transform models get, and mixins are still declarations
1384
+ you never instantiate directly.
1385
+
1386
+ That is also the one way to keep a **custom field type** required in the
1387
+ constructor. A package that ships its own field type can declare the field on a
1388
+ mixin carrying a transform that lists its constructor, and models mixing it in
1389
+ get the field with its requiredness intact:
1390
+
1391
+ ```python
1392
+ from typing import dataclass_transform
1393
+
1394
+ from plain.postgres import Field
1395
+ from plain.passwords.types import PasswordField
1396
+
1397
+
1398
+ @dataclass_transform(kw_only_default=True, field_specifiers=(PasswordField,))
1399
+ class _PasswordFieldSpec: ...
1400
+
1401
+
1402
+ class PasswordMixin(_PasswordFieldSpec):
1403
+ password: Field[str] = PasswordField()
952
1404
  ```
953
1405
 
1406
+ A model mixing in `PasswordMixin` now gets `password` as a required constructor
1407
+ argument, so `User(email="a@b.com")` is a type error again. The specifier list
1408
+ has to name every constructor the mixin's own body uses, core ones included.
1409
+ Declared in the model's own body instead, `password` stays optional to the
1410
+ checker — a model body is governed by the specifier list `plain.postgres`
1411
+ declares, which a package outside it can't extend.
1412
+
954
1413
  ### Encrypted fields
955
1414
 
956
1415
  Encrypted fields transparently encrypt values before writing to the database and decrypt on read. Use them for third-party credentials, API keys, OAuth tokens, and other secrets your application needs back in plaintext.
@@ -959,28 +1418,46 @@ This is **not** for passwords or tokens you issue — those should be hashed (on
959
1418
 
960
1419
  ```python
961
1420
  from plain import postgres
962
- from plain.postgres import types
1421
+ from plain.postgres import EncryptedField, Field, types
963
1422
 
964
1423
 
965
1424
  @postgres.register_model
966
1425
  class Integration(postgres.Model):
967
- name: str = types.TextField(max_length=100)
968
- api_key: str = types.EncryptedTextField(max_length=200)
969
- credentials: dict = types.EncryptedJSONField(required=False, allow_null=True)
1426
+ name: Field[str] = types.TextField(max_length=100)
1427
+ api_key: EncryptedField[str] = types.EncryptedTextField(max_length=200)
1428
+ credentials: EncryptedField[dict | None] = types.EncryptedJSONField(
1429
+ required=False, allow_null=True, default=None
1430
+ )
970
1431
  ```
971
1432
 
1433
+ Annotate encrypted fields `EncryptedField[T]`, not `Field[T]`. The annotation is
1434
+ what the type checker reads, and `EncryptedField[T]` is the `Field[T]` subclass
1435
+ that declares the blocked conditions — with a plain `Field[T]`,
1436
+ `Integration.api_key.equals("x")` type-checks its way to a runtime `TypeError`
1437
+ instead of being rejected at the call site. It types the constructor exactly as
1438
+ `Field[T]` does.
1439
+
972
1440
  Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived from `SECRET_KEY`. The `cryptography` package is required — install it with `pip install cryptography`.
973
1441
 
974
1442
  **Available fields:**
975
1443
 
1444
+ - `EncryptedField[T]` — the annotation type; also the shared base the two fields below derive from.
976
1445
  - `EncryptedTextField` — encrypts text, stored as `text` in the database regardless of `max_length` (ciphertext is longer than plaintext). `max_length` is enforced on the plaintext value during validation.
977
1446
  - `EncryptedJSONField` — serializes to JSON, encrypts, and stores as `text`. Supports custom `encoder` and `decoder` parameters (same as `JSONField`).
978
1447
 
979
1448
  **Limitations:**
980
1449
 
981
- - **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
1450
+ - **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported. Comparing against a value raises `TypeError` rather than silently matching nothing — both `filter(api_key="x")` and the typed [condition methods](#typed-conditions-with-where) (`equals`, `contains`, …), which are also rejected at the call site when the field is annotated `EncryptedField[T]`. `filter(api_key=None)` still rewrites to `IS NULL`.
1451
+ - **`get_or_create()` must not look up an encrypted field.** `get_or_create(api_key="k")` raises, and the error says to move the value into `defaults=`. This is a deliberate break: it previously "worked" by creating a new row on every call, because the lookup could never match existing ciphertext. An encrypted value can be written, just not looked up:
1452
+
1453
+ ```python
1454
+ Integration.query.get_or_create(name="acme", defaults={"api_key": "k"})
1455
+ ```
1456
+
1457
+ The same applies to `upsert()`, and to an expression right-hand side like `filter(api_key=F("name"))` — the column is still ciphertext.
1458
+
982
1459
  - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
983
- - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
1460
+ - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` has no persistent default at all — even `{}` serializes to text that would need ciphertext — so pair `allow_null=True` with `default=None`, which stores nothing and just marks the field optional in the constructor.
984
1461
 
985
1462
  **Key rotation:**
986
1463
 
@@ -996,12 +1473,12 @@ Use [`ForeignKeyField`](./fields/related.py#ForeignKeyField) for many-to-one and
996
1473
 
997
1474
  ```python
998
1475
  from plain import postgres
999
- from plain.postgres import types
1476
+ from plain.postgres import Field, types
1000
1477
 
1001
1478
 
1002
1479
  @postgres.register_model
1003
1480
  class Book(postgres.Model):
1004
- title: str = types.TextField(max_length=200)
1481
+ title: Field[str] = types.TextField(max_length=200)
1005
1482
  author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
1006
1483
  tags = types.ManyToManyField("Tag")
1007
1484
  ```
@@ -1019,6 +1496,8 @@ book.author.name # one query — loads the rest of the row
1019
1496
 
1020
1497
  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.
1021
1498
 
1499
+ A foreign key with no value raises `RelatedObjectDoesNotExist` on access. That attribute still lives on the descriptor at runtime, but class-level access is now typed as the related model (that is what makes `Book.author.name.equals(...)` work), so `Book.author.RelatedObjectDoesNotExist` is a type error. Catch it as `Author.DoesNotExist` — the exception subclasses both that and `AttributeError` — or as `AttributeError`.
1500
+
1022
1501
  The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
1023
1502
 
1024
1503
  ### Constraints are checked immediately
@@ -1034,21 +1513,26 @@ A migration can add a column, backfill it in `RunPython`, and drop or alter colu
1034
1513
  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):
1035
1514
 
1036
1515
  ```python
1516
+ from typing import ClassVar
1517
+
1037
1518
  from plain import postgres
1038
- from plain.postgres import types
1519
+ from plain.postgres import Field, types
1039
1520
 
1040
1521
 
1041
1522
  @postgres.register_model
1042
1523
  class Author(postgres.Model):
1043
- name: str = types.TextField(max_length=200)
1044
- # Explicit reverse accessor for all books by this author
1045
- books = types.ReverseForeignKey(to="Book", field="author")
1524
+ name: Field[str] = types.TextField(max_length=200)
1525
+ # Explicit reverse accessor for all books by this author.
1526
+ # ClassVar keeps it out of the typed constructor (it's an accessor, not a field).
1527
+ books: ClassVar[types.ReverseForeignKey[Book]] = types.ReverseForeignKey(
1528
+ to="Book", field="author"
1529
+ )
1046
1530
 
1047
1531
 
1048
1532
  @postgres.register_model
1049
1533
  class Book(postgres.Model):
1050
- title: str = types.TextField(max_length=200)
1051
- author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
1534
+ title: Field[str] = types.TextField(max_length=200)
1535
+ author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
1052
1536
 
1053
1537
 
1054
1538
  # Usage
@@ -1065,14 +1549,16 @@ For many-to-many relationships:
1065
1549
  ```python
1066
1550
  @postgres.register_model
1067
1551
  class Feature(postgres.Model):
1068
- name: str = types.TextField(max_length=100)
1552
+ name: Field[str] = types.TextField(max_length=100)
1069
1553
  # Explicit reverse accessor for all cars with this feature
1070
- cars = types.ReverseManyToMany(to="Car", field="features")
1554
+ cars: ClassVar[types.ReverseManyToMany[Car]] = types.ReverseManyToMany(
1555
+ to="Car", field="features"
1556
+ )
1071
1557
 
1072
1558
 
1073
1559
  @postgres.register_model
1074
1560
  class Car(postgres.Model):
1075
- model: str = types.TextField(max_length=100)
1561
+ model: Field[str] = types.TextField(max_length=100)
1076
1562
  features = types.ManyToManyField(Feature)
1077
1563
 
1078
1564
 
@@ -1091,16 +1577,16 @@ for car in feature.cars.all():
1091
1577
 
1092
1578
  Reverse relations are optional — if you don't declare them, the automatic `{model}_set` accessor still works.
1093
1579
 
1094
- To get type checking for custom QuerySet methods on reverse relations, specify the QuerySet type as a second parameter:
1580
+ Annotate reverse relations with `ClassVar` — they're class-level accessors, not constructor fields, so `ClassVar` keeps them out of the typed `Model(...)` constructor (same as `query`). To get type checking for custom QuerySet methods, specify the QuerySet type as a second parameter:
1095
1581
 
1096
1582
  ```python
1097
1583
  # Basic usage
1098
- books: types.ReverseForeignKey[Book] = types.ReverseForeignKey(
1584
+ books: ClassVar[types.ReverseForeignKey[Book]] = types.ReverseForeignKey(
1099
1585
  to="Book", field="author"
1100
1586
  )
1101
1587
 
1102
1588
  # With custom QuerySet for proper method recognition
1103
- books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(
1589
+ books: ClassVar[types.ReverseForeignKey[Book, BookQuerySet]] = types.ReverseForeignKey(
1104
1590
  to="Book", field="author"
1105
1591
  )
1106
1592
 
@@ -1117,8 +1603,8 @@ author.books.query.published()
1117
1603
  ```python
1118
1604
  @postgres.register_model
1119
1605
  class User(postgres.Model):
1120
- email: str = types.EmailField()
1121
- age: int = types.IntegerField()
1606
+ email: Field[str] = types.EmailField()
1607
+ age: Field[int] = types.IntegerField()
1122
1608
 
1123
1609
  model_options = postgres.Options(
1124
1610
  constraints=[
@@ -1156,7 +1642,7 @@ except (psycopg.IntegrityError, ValidationError):
1156
1642
  ... # lost a race — reload and retry, or report it
1157
1643
  ```
1158
1644
 
1159
- For a plain insert-or-update with no per-row logic, `bulk_create(..., update_conflicts=True, unique_fields=[...])` is an atomic upsert with no race to catch.
1645
+ For a plain insert-or-update with no per-row logic there's no race to catch in the first place: `upsert(**values, unique_fields=[...])` for one row and `bulk_upsert(objs, update_fields=[...], unique_fields=[...])` for many are each a single atomic statement.
1160
1646
 
1161
1647
  ### Indexes and constraints
1162
1648
 
@@ -1164,9 +1650,9 @@ You can optimize queries and ensure data integrity with indexes and constraints.
1164
1650
 
1165
1651
  ```python
1166
1652
  class User(postgres.Model):
1167
- email: str = types.EmailField()
1168
- username: str = types.TextField(max_length=150)
1169
- age: int = types.IntegerField()
1653
+ email: Field[str] = types.EmailField()
1654
+ username: Field[str] = types.TextField(max_length=150)
1655
+ age: Field[int] = types.IntegerField()
1170
1656
 
1171
1657
  model_options = postgres.Options(
1172
1658
  indexes=[
@@ -1241,14 +1727,14 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
1241
1727
  ```python
1242
1728
  # Bad — full table scan on every filtered query
1243
1729
  class Order(postgres.Model):
1244
- status: str = types.TextField(max_length=20)
1245
- created_at: datetime = types.DateTimeField()
1730
+ status: Field[str] = types.TextField(max_length=20)
1731
+ created_at: Field[datetime] = types.DateTimeField()
1246
1732
 
1247
1733
 
1248
1734
  # Good — indexed for common queries
1249
1735
  class Order(postgres.Model):
1250
- status: str = types.TextField(max_length=20)
1251
- created_at: datetime = types.DateTimeField()
1736
+ status: Field[str] = types.TextField(max_length=20)
1737
+ created_at: Field[datetime] = types.DateTimeField()
1252
1738
 
1253
1739
  model_options = postgres.Options(
1254
1740
  indexes=[postgres.Index(fields=["status", "-created_at"])],
@@ -1293,10 +1779,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
1293
1779
 
1294
1780
  ```python
1295
1781
  # Bad — NULL and "" both mean "empty"
1296
- nickname: str = types.TextField(max_length=50, allow_null=True)
1782
+ nickname: Field[str] = types.TextField(max_length=50, allow_null=True)
1297
1783
 
1298
1784
  # Good — single empty representation
1299
- nickname: str = types.TextField(max_length=50, default="")
1785
+ nickname: Field[str] = types.TextField(max_length=50, default="")
1300
1786
  ```
1301
1787
 
1302
1788
  ## Forms