plain.postgres 0.119.0__tar.gz → 0.120.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 (300) hide show
  1. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/PKG-INFO +138 -13
  2. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/CHANGELOG.md +18 -0
  3. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/README.md +137 -12
  4. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +38 -8
  5. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/base.py +103 -11
  6. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/encrypted.py +17 -0
  7. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_descriptors.py +1 -1
  8. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/reverse_descriptors.py +1 -1
  9. plain_postgres-0.120.0/plain/postgres/preflight/models.py +704 -0
  10. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/types.pyi +18 -12
  11. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/pyproject.toml +1 -1
  12. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/delete.py +1 -1
  13. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/trees.py +1 -1
  14. plain_postgres-0.120.0/tests/internal/test_preflight_fk_annotation.py +585 -0
  15. plain_postgres-0.120.0/tests/internal/test_preflight_nullable_default.py +109 -0
  16. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_stub_runtime_conformance.py +4 -2
  17. plain_postgres-0.120.0/tests/internal/test_typed_where_internals.py +204 -0
  18. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_unresolved_relation_refs.py +5 -1
  19. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_typed_where.py +12 -0
  20. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/conditions_encrypted.py +12 -0
  21. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/conditions_value_types.py +45 -0
  22. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_required_fields.py +20 -0
  23. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_unknown_kwargs.py +19 -0
  24. plain_postgres-0.120.0/tests/typing/relations_string_reference.py +135 -0
  25. plain_postgres-0.119.0/plain/postgres/preflight/models.py +0 -320
  26. plain_postgres-0.119.0/tests/internal/test_typed_where_internals.py +0 -101
  27. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/.gitignore +0 -0
  28. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/CLAUDE.md +0 -0
  29. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/LICENSE +0 -0
  30. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/README.md +0 -0
  31. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/__init__.py +0 -0
  32. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/adapters.py +0 -0
  33. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  34. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/aggregates.py +0 -0
  35. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/base.py +0 -0
  36. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/__init__.py +0 -0
  37. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/converge.py +0 -0
  38. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/core.py +0 -0
  39. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/decorators.py +0 -0
  40. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/diagnose.py +0 -0
  41. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/migrations.py +0 -0
  42. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/schema.py +0 -0
  43. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/sync.py +0 -0
  44. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/config.py +0 -0
  45. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/connection.py +0 -0
  46. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/constants.py +0 -0
  47. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/constraints.py +0 -0
  48. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/__init__.py +0 -0
  49. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/analysis.py +0 -0
  50. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/corrections.py +0 -0
  51. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/planning.py +0 -0
  52. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/database_url.py +0 -0
  53. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/databases.py +0 -0
  54. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/db.py +0 -0
  55. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/ddl.py +0 -0
  56. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/default_settings.py +0 -0
  57. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/deletion.py +0 -0
  58. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/dialect.py +0 -0
  59. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/entrypoints.py +0 -0
  60. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/enums.py +0 -0
  61. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/exceptions.py +0 -0
  62. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/expressions.py +0 -0
  63. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/__init__.py +0 -0
  64. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/binary.py +0 -0
  65. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/boolean.py +0 -0
  66. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/duration.py +0 -0
  67. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/json.py +0 -0
  68. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/mixins.py +0 -0
  69. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/network.py +0 -0
  70. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/numeric.py +0 -0
  71. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/primary_key.py +0 -0
  72. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related.py +0 -0
  73. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_lookups.py +0 -0
  74. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_managers.py +0 -0
  75. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_typed.py +0 -0
  76. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/reverse_related.py +0 -0
  77. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/temporal.py +0 -0
  78. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/text.py +0 -0
  79. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/timezones.py +0 -0
  80. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/uuid.py +0 -0
  81. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/forms.py +0 -0
  82. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/__init__.py +0 -0
  83. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/comparison.py +0 -0
  84. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/datetime.py +0 -0
  85. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/math.py +0 -0
  86. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/mixins.py +0 -0
  87. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/random.py +0 -0
  88. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/text.py +0 -0
  89. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/uuid.py +0 -0
  90. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/window.py +0 -0
  91. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/indexes.py +0 -0
  92. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/__init__.py +0 -0
  93. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/__init__.py +0 -0
  94. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  95. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  96. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  97. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/context.py +0 -0
  98. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/helpers.py +0 -0
  99. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/ownership.py +0 -0
  100. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/runner.py +0 -0
  101. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/types.py +0 -0
  102. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/schema.py +0 -0
  103. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/lookups.py +0 -0
  104. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/meta.py +0 -0
  105. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/middleware.py +0 -0
  106. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/__init__.py +0 -0
  107. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/autodetector.py +0 -0
  108. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/baselines.py +0 -0
  109. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/exceptions.py +0 -0
  110. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/executor.py +0 -0
  111. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/graph.py +0 -0
  112. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/loader.py +0 -0
  113. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/migration.py +0 -0
  114. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  115. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/base.py +0 -0
  116. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/fields.py +0 -0
  117. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/models.py +0 -0
  118. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/special.py +0 -0
  119. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/optimizer.py +0 -0
  120. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/questioner.py +0 -0
  121. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/recorder.py +0 -0
  122. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/reset.py +0 -0
  123. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/serializer.py +0 -0
  124. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/state.py +0 -0
  125. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/utils.py +0 -0
  126. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/writer.py +0 -0
  127. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/options.py +0 -0
  128. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/otel.py +0 -0
  129. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/preflight/__init__.py +0 -0
  130. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/preflight/database.py +0 -0
  131. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/preflight/indexes.py +0 -0
  132. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/query.py +0 -0
  133. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/query_utils.py +0 -0
  134. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/registry.py +0 -0
  135. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/schema.py +0 -0
  136. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/schema_lock.py +0 -0
  137. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/selectable.py +0 -0
  138. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sources.py +0 -0
  139. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/__init__.py +0 -0
  140. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/compiler.py +0 -0
  141. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/constants.py +0 -0
  142. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/datastructures.py +0 -0
  143. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/query.py +0 -0
  144. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/where.py +0 -0
  145. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/test/__init__.py +0 -0
  146. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/test/database.py +0 -0
  147. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/test/pytest.py +0 -0
  148. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/transaction.py +0 -0
  149. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/types.py +0 -0
  150. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/utils.py +0 -0
  151. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/forms.py +0 -0
  152. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  153. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  154. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  155. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  156. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  157. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  158. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  159. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  160. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  161. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  162. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  163. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  164. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  165. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  166. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  167. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  168. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  169. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  170. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py +0 -0
  171. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0020_stringconditionsexample.py +0 -0
  172. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0021_returningevent.py +0 -0
  173. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0022_upsertitem.py +0 -0
  174. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0023_upsertpair.py +0 -0
  175. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0024_upserttenant_upsertvaluekey_upsertscoped.py +0 -0
  176. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0025_upsertfloatkey.py +0 -0
  177. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0026_upsertdecimalkey.py +0 -0
  178. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0027_upsertstamped.py +0 -0
  179. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0028_aliascollisionexample.py +0 -0
  180. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/__init__.py +0 -0
  181. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/__init__.py +0 -0
  182. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/alias_collisions.py +0 -0
  183. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/constraints.py +0 -0
  184. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/defaults.py +0 -0
  185. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/encrypted.py +0 -0
  186. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/forms.py +0 -0
  187. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/indexes.py +0 -0
  188. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/iteration.py +0 -0
  189. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/mixins.py +0 -0
  190. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/nullability.py +0 -0
  191. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/querysets.py +0 -0
  192. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/relationships.py +0 -0
  193. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/returning.py +0 -0
  194. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/shadowing.py +0 -0
  195. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/storage_parameters.py +0 -0
  196. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/string_conditions.py +0 -0
  197. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/unregistered.py +0 -0
  198. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/upsert.py +0 -0
  199. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/urls.py +0 -0
  200. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/views.py +0 -0
  201. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/settings.py +0 -0
  202. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/urls.py +0 -0
  203. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/conftest.py +0 -0
  204. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/conftest_convergence.py +0 -0
  205. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/conftest.py +0 -0
  206. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_apply_replan.py +0 -0
  207. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  208. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_autodetector_type_change.py +0 -0
  209. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_baselines.py +0 -0
  210. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_conflict_lock_order.py +0 -0
  211. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_conflict_sort_value.py +0 -0
  212. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_isolation.py +0 -0
  213. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_lifecycle.py +0 -0
  214. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_pool.py +0 -0
  215. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_self_heal.py +0 -0
  216. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_constraint_violation_error.py +0 -0
  217. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence.py +0 -0
  218. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_constraints.py +0 -0
  219. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_defaults.py +0 -0
  220. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_fk.py +0 -0
  221. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_indexes.py +0 -0
  222. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_nullability.py +0 -0
  223. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  224. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_timeouts.py +0 -0
  225. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
  226. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_db_expression_defaults.py +0 -0
  227. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_diagnose.py +0 -0
  228. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_encrypted_internals.py +0 -0
  229. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_executor_connection_hook.py +0 -0
  230. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_fk_characterization.py +0 -0
  231. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_health.py +0 -0
  232. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_introspection.py +0 -0
  233. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_literal_default_persistence.py +0 -0
  234. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_lock_mode_sql.py +0 -0
  235. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_m2m_value_from_object.py +0 -0
  236. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_management_connection.py +0 -0
  237. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_meta_related_objects.py +0 -0
  238. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_migration_executor.py +0 -0
  239. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_migrations_reset.py +0 -0
  240. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_no_callable_defaults.py +0 -0
  241. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_otel_metrics.py +0 -0
  242. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  243. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  244. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  245. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_random_string_sql.py +0 -0
  246. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_replaces_removed.py +0 -0
  247. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_returning_internals.py +0 -0
  248. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_returning_order.py +0 -0
  249. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
  250. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_schema_lock.py +0 -0
  251. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_schema_normalize_type.py +0 -0
  252. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_schema_timeouts.py +0 -0
  253. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_shipped_baselines.py +0 -0
  254. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_typed_construction_preflight.py +0 -0
  255. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_writer_operation_options.py +0 -0
  256. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_bulk_upsert.py +0 -0
  257. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_create_update.py +0 -0
  258. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_database_url.py +0 -0
  259. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_databases.py +0 -0
  260. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_deferred_loading.py +0 -0
  261. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_delete_behaviors.py +0 -0
  262. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_encrypted_fields.py +0 -0
  263. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_exceptions.py +0 -0
  264. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_field_defaults.py +0 -0
  265. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_functions_uuid.py +0 -0
  266. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_integrity_error_mapping.py +0 -0
  267. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_iterator.py +0 -0
  268. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_m2m.py +0 -0
  269. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_manager_assignment.py +0 -0
  270. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_manual_pk.py +0 -0
  271. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_mixins.py +0 -0
  272. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_modelform_roundtrip.py +0 -0
  273. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_only_empty_defaults.py +0 -0
  274. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_order_by_expressions.py +0 -0
  275. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_queryset_ordered.py +0 -0
  276. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_queryset_repr.py +0 -0
  277. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_queryset_slicing.py +0 -0
  278. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_random_string_field.py +0 -0
  279. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_raw_query.py +0 -0
  280. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_read_only_transactions.py +0 -0
  281. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_related.py +0 -0
  282. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_related_instance_filter.py +0 -0
  283. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_returning.py +0 -0
  284. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_row_locking.py +0 -0
  285. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_select.py +0 -0
  286. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_typed_where_fk.py +0 -0
  287. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_upsert.py +0 -0
  288. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/README.md +0 -0
  289. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/bulk_writes.py +0 -0
  290. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_mixins.py +0 -0
  291. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_value_types.py +0 -0
  292. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/field_access.py +0 -0
  293. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/field_constructors.py +0 -0
  294. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/queryset_access.py +0 -0
  295. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/relations_foreign_key.py +0 -0
  296. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/relations_reverse.py +0 -0
  297. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/returning_writes.py +0 -0
  298. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/select_ladder.py +0 -0
  299. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/select_rows.py +0 -0
  300. {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/upsert_writes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plain.postgres
3
- Version: 0.119.0
3
+ Version: 0.120.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
@@ -61,12 +61,24 @@ class User(postgres.Model):
61
61
 
62
62
  Annotate each field with `Field[T]` (the value type) — that's what gives the
63
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.
64
+ types, unknown field names, and missing required fields.
65
+
66
+ Annotating is not per-model opt-in. `postgres.Model` carries the transform, so
67
+ the checker synthesizes every subclass's constructor from its annotated
68
+ attributes and nothing else. Drop the annotation from `email` above and it stops
69
+ being a constructor argument at all — `User(email="a@b.com")` is then rejected as
70
+ an unknown argument. The runtime doesn't care either way, but if you run a type
71
+ checker, every model has to be annotated.
72
+
73
+ A field is optional in that constructor only when its definition passes
74
+ `default=` (this is general, not nullable-specific — a `required=False` field
75
+ with no `default=` is still a required constructor arg), so nullable fields use
76
+ `Field[T | None]` with `default=None`. The runtime already treats a nullable
77
+ field as optional — constructing without it yields `None` — so `default=None`
78
+ exists for the checker; it persists nothing and changes no schema.
79
+ `plain preflight` lists the nullable fields still missing one
80
+ (`postgres.nullable_field_without_default`). DB-owned fields (`id`,
81
+ `create_now`, generated values) are auto-excluded from the constructor.
70
82
 
71
83
  Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
72
84
  your own — are the exception: their stub still types the value, but they are
@@ -217,6 +229,8 @@ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) cl
217
229
  `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
230
 
219
231
  ```python
232
+ from datetime import datetime
233
+
220
234
  from plain import postgres
221
235
  from plain.postgres import Field, types
222
236
 
@@ -226,6 +240,8 @@ class User(postgres.Model):
226
240
  email: Field[str] = types.EmailField()
227
241
  role: Field[str] = types.TextField(max_length=20)
228
242
  age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
243
+ created_at: Field[datetime] = types.DateTimeField(create_now=True)
244
+ updated_at: Field[datetime] = types.DateTimeField(update_now=True)
229
245
 
230
246
 
231
247
  # Each argument is a condition; multiple arguments are ANDed together.
@@ -235,7 +251,7 @@ admins = User.query.where(
235
251
  )
236
252
  ```
237
253
 
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 `~`:
254
+ Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `startswith`, and `endswith`, plus their case-insensitive forms `icontains`, `istartswith`, `iendswith`, and `iequals` (the typed spelling of `filter(field__iexact=...)`). Each returns a `Q`, so you can combine them with `|` and `&` or negate with `~`:
239
255
 
240
256
  ```python
241
257
  # Membership, negation, and OR
@@ -244,6 +260,47 @@ User.query.where(~User.role.equals("guest"))
244
260
  User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
245
261
  ```
246
262
 
263
+ `~` negates whatever it wraps, which is what you want for a composite condition but not for a null check. `is_null()` takes a flag, and the two compile differently:
264
+
265
+ ```python
266
+ User.query.where(~User.age.is_null()) # WHERE NOT ("age" IS NULL)
267
+ User.query.where(User.age.is_null(False)) # WHERE "age" IS NOT NULL
268
+ ```
269
+
270
+ Both match the same rows. `is_null(False)` is the conversion for `filter(age__isnull=False)` and emits the SQL you would write by hand, so reach for it and keep `~` for negating a condition that isn't a null check.
271
+
272
+ A comparison can also take another column of the same value type, instead of a value:
273
+
274
+ ```python
275
+ # Q(updated_at__gt=F("created_at"))
276
+ User.query.where(User.updated_at.gt(User.created_at))
277
+ ```
278
+
279
+ `equals`, `not_equal`, `gt`, `gte`, `lt`, and `lte` all accept one. The column has to belong to the same model, which `where()` checks on both sides, and it has to be the same value type — comparing an `int` column against a `str` one is a type error.
280
+
281
+ A **nullable column is a different value type** to the checker — `Field[int | None]` is not a `Field[int]` — so the two directions aren't the same. A non-null column accepts a nullable one on the right; a nullable one on the left won't accept a non-null column, because there's no way to name its value type without the `None`. Compare the other way round, or drop to `filter(age__lt=F("other"))`.
282
+
283
+ An `F()` expression works too, but that arm is untyped: an expression's output type isn't tracked, so nothing checks it against the column. `F()` is the same escape hatch here that it is in `filter()`.
284
+
285
+ **A condition _is_ a `Q`**, so these replace `Q` everywhere, not just in `filter()`. Anything that takes a `Q` takes one unchanged — `When()`, including the ones inside a `Case()`, and an aggregate's `filter=`:
286
+
287
+ ```python
288
+ from plain.postgres.aggregates import Count
289
+ from plain.postgres.expressions import Case, Value, When
290
+
291
+ User.query.annotate(
292
+ tier=Case(When(User.age.gte(18), then=Value("adult")), default=Value("minor"))
293
+ )
294
+ User.query.values("role").annotate(adults=Count("id", filter=User.age.gte(18)))
295
+ ```
296
+
297
+ ```sql
298
+ -- the Case annotation
299
+ CASE WHEN "age" >= %s THEN %s ELSE %s END AS "tier"
300
+ -- the Count annotation
301
+ COUNT("id") FILTER (WHERE "age" >= %s) AS "adults"
302
+ ```
303
+
247
304
  Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
248
305
 
249
306
  ```python
@@ -267,9 +324,24 @@ A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no tr
267
324
 
268
325
  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
326
 
327
+ **`where()` preserves the order you wrote; `filter()` sorted its kwargs alphabetically.** Converting a multi-condition `filter()` can change the WHERE clause text and the parameter order, though not which rows it matches:
328
+
329
+ ```sql
330
+ -- filter(role="admin", email="a@example.com")
331
+ WHERE ("email" = %s AND "role" = %s) -- params: ('a@example.com', 'admin')
332
+ -- where(User.role.equals("admin"), User.email.equals("a@example.com"))
333
+ WHERE ("role" = %s AND "email" = %s) -- params: ('admin', 'a@example.com')
334
+ ```
335
+
336
+ Kwargs you already wrote alphabetically convert unchanged. Where the source order wasn't alphabetical, preserving it changes the predicate structure — a test asserting on generated SQL notices, and so can `pg_stat_statements`, which groups by structure rather than literal text and files the reordered statement as a new entry.
337
+
338
+ **Conditions are strict where `filter()` was lenient — to the type checker.** `equals` takes the field's value type, so `User.age.equals("18")` and a `Field[UUID]`'s `.equals("3f2504e0-4f89-11d3-9a0c-0305e82c3301")` are type errors where `filter(age="18")` and `filter(uuid="3f2504e0-4f89-11d3-9a0c-0305e82c3301")` were not. Runtime coercion is unchanged: `User.age.equals("18")` still coerces the string and runs, exactly as the kwarg did.
339
+
340
+ So the strictness lands on the caller. Code holding a string from a CLI argument, URL segment, or session parses it first — `int(raw)`, `uuid.UUID(raw)` — and an invalid value raises `ValueError` there, before the ORM is involved, next to the input that was wrong.
341
+
270
342
  **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
343
 
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`.
344
+ [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`. That holds on either side of a comparison: an encrypted column can't be the column another column is compared _against_ either, since that would match plaintext against ciphertext.
273
345
 
274
346
  ### Selecting columns with select()
275
347
 
@@ -1479,10 +1551,63 @@ from plain.postgres import Field, types
1479
1551
  @postgres.register_model
1480
1552
  class Book(postgres.Model):
1481
1553
  title: Field[str] = types.TextField(max_length=200)
1482
- author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
1554
+ author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
1483
1555
  tags = types.ManyToManyField("Tag")
1484
1556
  ```
1485
1557
 
1558
+ ### Referring to a model by name
1559
+
1560
+ `ForeignKeyField` also takes a `"package.Model"` string (or `"self"`) instead of
1561
+ the class. That is a **runtime** device, for the cases where the class isn't
1562
+ importable at the point of declaration: a circular reference between two models,
1563
+ a self-reference, or a framework package pointing at the app's own `User`.
1564
+
1565
+ The annotation is unaffected — a string-referenced foreign key is annotated
1566
+ `Field[Related]` exactly like a class-referenced one, and gets the same typed
1567
+ surface: `Model.user.email.equals(...)` traversal, `Model.user.id.equals(...)`
1568
+ conditions, and a typed `Model(user=...)` constructor. The string says nothing
1569
+ to the checker, so the annotation is where `Related` comes from; import it under
1570
+ `TYPE_CHECKING` when importing it for real would be the cycle you were avoiding:
1571
+
1572
+ ```python
1573
+ from __future__ import annotations
1574
+
1575
+ from typing import TYPE_CHECKING
1576
+
1577
+ from plain import postgres
1578
+ from plain.postgres import Field, types
1579
+
1580
+ if TYPE_CHECKING:
1581
+ from app.users.models import User
1582
+
1583
+
1584
+ @postgres.register_model
1585
+ class PinnedNavItem(postgres.Model):
1586
+ # Cross-package: plain.admin can't import the app's User at runtime.
1587
+ user: Field[User] = types.ForeignKeyField("users.User", on_delete=postgres.CASCADE)
1588
+
1589
+
1590
+ @postgres.register_model
1591
+ class TreeNode(postgres.Model):
1592
+ name: Field[str] = types.TextField(max_length=100)
1593
+ # Self-reference, nullable: `Field[T | None]` plus `default=None`.
1594
+ parent: Field[TreeNode | None] = types.ForeignKeyField(
1595
+ "self", on_delete=postgres.CASCADE, allow_null=True, default=None
1596
+ )
1597
+ ```
1598
+
1599
+ What does _not_ work is annotating the field with the related model itself
1600
+ (`user: User = types.ForeignKeyField("users.User", ...)`). That names a model
1601
+ instance rather than a field, so the checker never sees a descriptor: class
1602
+ access is a `User` rather than `type[User]`, `Model.user.id` is an `int`, and
1603
+ the condition methods are gone. `plain preflight`'s
1604
+ `postgres.foreign_key_annotated_as_value` finds these: it reports a foreign key
1605
+ whose annotation — on the model or on any of its base classes — names the
1606
+ related model. It asks for positive evidence, so it stays quiet when it can't
1607
+ establish that: an unannotated foreign key (no constructor argument at all), a
1608
+ `ClassVar[...]` one, and any spelling whose meaning it can't read. Treat a clean
1609
+ run as "nothing found", not "nothing to find".
1610
+
1486
1611
  ### Foreign key access
1487
1612
 
1488
1613
  Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
@@ -1765,12 +1890,12 @@ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional
1765
1890
 
1766
1891
  ```python
1767
1892
  # Bad — blindly using CASCADE everywhere
1768
- company: Company = types.ForeignKeyField(
1769
- "Company", on_delete=postgres.CASCADE
1893
+ company: Field[Company] = types.ForeignKeyField(
1894
+ Company, on_delete=postgres.CASCADE
1770
1895
  ) # deleting company deletes invoices!
1771
1896
 
1772
1897
  # Good — block the delete while invoices reference the company
1773
- company: Company = types.ForeignKeyField("Company", on_delete=postgres.RESTRICT)
1898
+ company: Field[Company] = types.ForeignKeyField(Company, on_delete=postgres.RESTRICT)
1774
1899
  ```
1775
1900
 
1776
1901
  #### No `allow_null` on string fields
@@ -1,5 +1,23 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.120.0](https://github.com/dropseed/plain/releases/plain-postgres@0.120.0) (2026-09-21)
4
+
5
+ ### What's changed
6
+
7
+ - String-referenced foreign keys get the typed condition surface. `ForeignKeyField("users.User", ...)` now returns the same typed descriptor as the class-referenced form, so the model annotates it `user: Field[User]` (with a `TYPE_CHECKING` import where the runtime import would cycle) and `Model.user.email.equals(...)`, `Model.user.id.is_in(...)` and typed construction all work through it. The rule is now one rule for every foreign key: annotate `Field[Related]` (`Field[Related | None]` plus `default=None` when nullable, `Field[Foo | None]` for `"self"`); the string form is a runtime device for import cycles and cross-package references only. New preflight warning `postgres.foreign_key_annotated_as_value` reports a foreign key whose annotation names the related model instead of `Field[...]`, with the exact rewrite. It exists because the type checker is silent on the old non-nullable spelling: PEP 681 only compares a field's declared type against the annotation when `default=` is passed ([b072153088](https://github.com/dropseed/plain/commit/b072153088))
8
+ - `iequals`, `istartswith` and `iendswith` on `Field[str]`, the case-insensitive counterparts of `equals`/`startswith`/`endswith` (the `iexact`/`istartswith`/`iendswith` lookups). Blocked on encrypted fields like the other string conditions ([26c35167d8](https://github.com/dropseed/plain/commit/26c35167d8))
9
+ - Comparisons against another column: `equals`, `not_equal`, `gt`, `gte`, `lt` and `lte` accept a field of the same value type, so `Post.query.where(Post.updated_at.gt(Post.created_at))` replaces `filter(updated_at__gt=F("created_at"))` and type-checks. A non-null column accepts a nullable one on the right; the reverse can't be expressed. Traversed fields carry their relation prefix; a column from another model trips the cross-model guard; an encrypted column is refused on either side. `F()` and other expressions are still accepted as the untyped escape hatch ([26c35167d8](https://github.com/dropseed/plain/commit/26c35167d8))
10
+ - New preflight warning `postgres.nullable_field_without_default`: an `allow_null=True` field with no declared default is optional in the constructor at runtime but required to a type checker; adding `default=None` makes them agree and changes no schema. `Field.has_declared_default()` reports a declared `default=None` on the fields that accept only that default ([55d2a71790](https://github.com/dropseed/plain/commit/55d2a71790))
11
+ - Docs corrected and filled from the internal conversion pass: typed construction is opt-in at runtime but not for a type-checked app (an unannotated model's constructor calls are unknown-argument errors, so every model must be annotated); `is_null(False)` is the conversion for `__isnull=False` (`~x.is_null()` compiles to `NOT (x IS NULL)`); typed conditions are a `Q` replacement, accepted by `When()` and an aggregate's `filter=` unchanged; `where()` preserves written condition order where `filter()` sorted kwargs alphabetically, so a converted multi-condition filter can change the emitted clause order; value-type strictness is a type-checker guard and runtime coercion is unchanged ([55d2a71790](https://github.com/dropseed/plain/commit/55d2a71790)) ([10236185ea](https://github.com/dropseed/plain/commit/10236185ea))
12
+
13
+ ### Upgrade instructions
14
+
15
+ - Rewrite every string-referenced foreign key annotation from `X: Related = types.ForeignKeyField("...")` to `X: Field[Related] = types.ForeignKeyField("...")`, nullable ones to `Field[Related | None]` with `default=None`. Import `Related` under `if TYPE_CHECKING:` where the runtime import would cycle. This corrects the 0.119.0 instruction to annotate a string forward reference with the bare model type. `plain preflight` lists the fields to change.
16
+ - If the project runs a type checker, annotate every model; 0.119.0 called annotation optional, which is true only at runtime.
17
+ - Add `default=None` to nullable fields that lack a declared default; `plain preflight` lists them. No behavior or schema change.
18
+ - When converting a multi-kwarg `filter()` to `where()`, list the conditions in the order `filter()` sorted them (alphabetical by field name) if anything pins the generated SQL.
19
+ - `filter()`, `exclude()`, `values()`, `values_list()`, `only()`, `defer()` and string `annotate()` are unchanged.
20
+
3
21
  ## [0.119.0](https://github.com/dropseed/plain/releases/plain-postgres@0.119.0) (2026-09-20)
4
22
 
5
23
  ### What's changed
@@ -48,12 +48,24 @@ class User(postgres.Model):
48
48
 
49
49
  Annotate each field with `Field[T]` (the value type) — that's what gives the
50
50
  model a type-checked constructor: `User(email="a@b.com")` flags wrong value
51
- types, unknown field names, and missing required fields. A field is optional in
52
- that constructor only when its definition passes `default=` (this is general,
53
- not nullable-specific — a `required=False` field with no `default=` is still a
54
- required constructor arg), so nullable fields use `Field[T | None]` with
55
- `default=None`. DB-owned fields (`id`, `create_now`, generated values) are
56
- auto-excluded from the constructor.
51
+ types, unknown field names, and missing required fields.
52
+
53
+ Annotating is not per-model opt-in. `postgres.Model` carries the transform, so
54
+ the checker synthesizes every subclass's constructor from its annotated
55
+ attributes and nothing else. Drop the annotation from `email` above and it stops
56
+ being a constructor argument at all — `User(email="a@b.com")` is then rejected as
57
+ an unknown argument. The runtime doesn't care either way, but if you run a type
58
+ checker, every model has to be annotated.
59
+
60
+ A field is optional in that constructor only when its definition passes
61
+ `default=` (this is general, not nullable-specific — a `required=False` field
62
+ with no `default=` is still a required constructor arg), so nullable fields use
63
+ `Field[T | None]` with `default=None`. The runtime already treats a nullable
64
+ field as optional — constructing without it yields `None` — so `default=None`
65
+ exists for the checker; it persists nothing and changes no schema.
66
+ `plain preflight` lists the nullable fields still missing one
67
+ (`postgres.nullable_field_without_default`). DB-owned fields (`id`,
68
+ `create_now`, generated values) are auto-excluded from the constructor.
57
69
 
58
70
  Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
59
71
  your own — are the exception: their stub still types the value, but they are
@@ -204,6 +216,8 @@ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) cl
204
216
  `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:
205
217
 
206
218
  ```python
219
+ from datetime import datetime
220
+
207
221
  from plain import postgres
208
222
  from plain.postgres import Field, types
209
223
 
@@ -213,6 +227,8 @@ class User(postgres.Model):
213
227
  email: Field[str] = types.EmailField()
214
228
  role: Field[str] = types.TextField(max_length=20)
215
229
  age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
230
+ created_at: Field[datetime] = types.DateTimeField(create_now=True)
231
+ updated_at: Field[datetime] = types.DateTimeField(update_now=True)
216
232
 
217
233
 
218
234
  # Each argument is a condition; multiple arguments are ANDed together.
@@ -222,7 +238,7 @@ admins = User.query.where(
222
238
  )
223
239
  ```
224
240
 
225
- 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 `~`:
241
+ Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `startswith`, and `endswith`, plus their case-insensitive forms `icontains`, `istartswith`, `iendswith`, and `iequals` (the typed spelling of `filter(field__iexact=...)`). Each returns a `Q`, so you can combine them with `|` and `&` or negate with `~`:
226
242
 
227
243
  ```python
228
244
  # Membership, negation, and OR
@@ -231,6 +247,47 @@ User.query.where(~User.role.equals("guest"))
231
247
  User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
232
248
  ```
233
249
 
250
+ `~` negates whatever it wraps, which is what you want for a composite condition but not for a null check. `is_null()` takes a flag, and the two compile differently:
251
+
252
+ ```python
253
+ User.query.where(~User.age.is_null()) # WHERE NOT ("age" IS NULL)
254
+ User.query.where(User.age.is_null(False)) # WHERE "age" IS NOT NULL
255
+ ```
256
+
257
+ Both match the same rows. `is_null(False)` is the conversion for `filter(age__isnull=False)` and emits the SQL you would write by hand, so reach for it and keep `~` for negating a condition that isn't a null check.
258
+
259
+ A comparison can also take another column of the same value type, instead of a value:
260
+
261
+ ```python
262
+ # Q(updated_at__gt=F("created_at"))
263
+ User.query.where(User.updated_at.gt(User.created_at))
264
+ ```
265
+
266
+ `equals`, `not_equal`, `gt`, `gte`, `lt`, and `lte` all accept one. The column has to belong to the same model, which `where()` checks on both sides, and it has to be the same value type — comparing an `int` column against a `str` one is a type error.
267
+
268
+ A **nullable column is a different value type** to the checker — `Field[int | None]` is not a `Field[int]` — so the two directions aren't the same. A non-null column accepts a nullable one on the right; a nullable one on the left won't accept a non-null column, because there's no way to name its value type without the `None`. Compare the other way round, or drop to `filter(age__lt=F("other"))`.
269
+
270
+ An `F()` expression works too, but that arm is untyped: an expression's output type isn't tracked, so nothing checks it against the column. `F()` is the same escape hatch here that it is in `filter()`.
271
+
272
+ **A condition _is_ a `Q`**, so these replace `Q` everywhere, not just in `filter()`. Anything that takes a `Q` takes one unchanged — `When()`, including the ones inside a `Case()`, and an aggregate's `filter=`:
273
+
274
+ ```python
275
+ from plain.postgres.aggregates import Count
276
+ from plain.postgres.expressions import Case, Value, When
277
+
278
+ User.query.annotate(
279
+ tier=Case(When(User.age.gte(18), then=Value("adult")), default=Value("minor"))
280
+ )
281
+ User.query.values("role").annotate(adults=Count("id", filter=User.age.gte(18)))
282
+ ```
283
+
284
+ ```sql
285
+ -- the Case annotation
286
+ CASE WHEN "age" >= %s THEN %s ELSE %s END AS "tier"
287
+ -- the Count annotation
288
+ COUNT("id") FILTER (WHERE "age" >= %s) AS "adults"
289
+ ```
290
+
234
291
  Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
235
292
 
236
293
  ```python
@@ -254,9 +311,24 @@ A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no tr
254
311
 
255
312
  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.
256
313
 
314
+ **`where()` preserves the order you wrote; `filter()` sorted its kwargs alphabetically.** Converting a multi-condition `filter()` can change the WHERE clause text and the parameter order, though not which rows it matches:
315
+
316
+ ```sql
317
+ -- filter(role="admin", email="a@example.com")
318
+ WHERE ("email" = %s AND "role" = %s) -- params: ('a@example.com', 'admin')
319
+ -- where(User.role.equals("admin"), User.email.equals("a@example.com"))
320
+ WHERE ("role" = %s AND "email" = %s) -- params: ('admin', 'a@example.com')
321
+ ```
322
+
323
+ Kwargs you already wrote alphabetically convert unchanged. Where the source order wasn't alphabetical, preserving it changes the predicate structure — a test asserting on generated SQL notices, and so can `pg_stat_statements`, which groups by structure rather than literal text and files the reordered statement as a new entry.
324
+
325
+ **Conditions are strict where `filter()` was lenient — to the type checker.** `equals` takes the field's value type, so `User.age.equals("18")` and a `Field[UUID]`'s `.equals("3f2504e0-4f89-11d3-9a0c-0305e82c3301")` are type errors where `filter(age="18")` and `filter(uuid="3f2504e0-4f89-11d3-9a0c-0305e82c3301")` were not. Runtime coercion is unchanged: `User.age.equals("18")` still coerces the string and runs, exactly as the kwarg did.
326
+
327
+ So the strictness lands on the caller. Code holding a string from a CLI argument, URL segment, or session parses it first — `int(raw)`, `uuid.UUID(raw)` — and an invalid value raises `ValueError` there, before the ORM is involved, next to the input that was wrong.
328
+
257
329
  **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.
258
330
 
259
- [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`.
331
+ [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`. That holds on either side of a comparison: an encrypted column can't be the column another column is compared _against_ either, since that would match plaintext against ciphertext.
260
332
 
261
333
  ### Selecting columns with select()
262
334
 
@@ -1466,10 +1538,63 @@ from plain.postgres import Field, types
1466
1538
  @postgres.register_model
1467
1539
  class Book(postgres.Model):
1468
1540
  title: Field[str] = types.TextField(max_length=200)
1469
- author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
1541
+ author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
1470
1542
  tags = types.ManyToManyField("Tag")
1471
1543
  ```
1472
1544
 
1545
+ ### Referring to a model by name
1546
+
1547
+ `ForeignKeyField` also takes a `"package.Model"` string (or `"self"`) instead of
1548
+ the class. That is a **runtime** device, for the cases where the class isn't
1549
+ importable at the point of declaration: a circular reference between two models,
1550
+ a self-reference, or a framework package pointing at the app's own `User`.
1551
+
1552
+ The annotation is unaffected — a string-referenced foreign key is annotated
1553
+ `Field[Related]` exactly like a class-referenced one, and gets the same typed
1554
+ surface: `Model.user.email.equals(...)` traversal, `Model.user.id.equals(...)`
1555
+ conditions, and a typed `Model(user=...)` constructor. The string says nothing
1556
+ to the checker, so the annotation is where `Related` comes from; import it under
1557
+ `TYPE_CHECKING` when importing it for real would be the cycle you were avoiding:
1558
+
1559
+ ```python
1560
+ from __future__ import annotations
1561
+
1562
+ from typing import TYPE_CHECKING
1563
+
1564
+ from plain import postgres
1565
+ from plain.postgres import Field, types
1566
+
1567
+ if TYPE_CHECKING:
1568
+ from app.users.models import User
1569
+
1570
+
1571
+ @postgres.register_model
1572
+ class PinnedNavItem(postgres.Model):
1573
+ # Cross-package: plain.admin can't import the app's User at runtime.
1574
+ user: Field[User] = types.ForeignKeyField("users.User", on_delete=postgres.CASCADE)
1575
+
1576
+
1577
+ @postgres.register_model
1578
+ class TreeNode(postgres.Model):
1579
+ name: Field[str] = types.TextField(max_length=100)
1580
+ # Self-reference, nullable: `Field[T | None]` plus `default=None`.
1581
+ parent: Field[TreeNode | None] = types.ForeignKeyField(
1582
+ "self", on_delete=postgres.CASCADE, allow_null=True, default=None
1583
+ )
1584
+ ```
1585
+
1586
+ What does _not_ work is annotating the field with the related model itself
1587
+ (`user: User = types.ForeignKeyField("users.User", ...)`). That names a model
1588
+ instance rather than a field, so the checker never sees a descriptor: class
1589
+ access is a `User` rather than `type[User]`, `Model.user.id` is an `int`, and
1590
+ the condition methods are gone. `plain preflight`'s
1591
+ `postgres.foreign_key_annotated_as_value` finds these: it reports a foreign key
1592
+ whose annotation — on the model or on any of its base classes — names the
1593
+ related model. It asks for positive evidence, so it stays quiet when it can't
1594
+ establish that: an unannotated foreign key (no constructor argument at all), a
1595
+ `ClassVar[...]` one, and any spelling whose meaning it can't read. Treat a clean
1596
+ run as "nothing found", not "nothing to find".
1597
+
1473
1598
  ### Foreign key access
1474
1599
 
1475
1600
  Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
@@ -1752,12 +1877,12 @@ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional
1752
1877
 
1753
1878
  ```python
1754
1879
  # Bad — blindly using CASCADE everywhere
1755
- company: Company = types.ForeignKeyField(
1756
- "Company", on_delete=postgres.CASCADE
1880
+ company: Field[Company] = types.ForeignKeyField(
1881
+ Company, on_delete=postgres.CASCADE
1757
1882
  ) # deleting company deletes invoices!
1758
1883
 
1759
1884
  # Good — block the delete while invoices reference the company
1760
- company: Company = types.ForeignKeyField("Company", on_delete=postgres.RESTRICT)
1885
+ company: Field[Company] = types.ForeignKeyField(Company, on_delete=postgres.RESTRICT)
1761
1886
  ```
1762
1887
 
1763
1888
  #### No `allow_null` on string fields
@@ -30,6 +30,11 @@ class Article(postgres.Model):
30
30
  created_at: Field[datetime] = types.DateTimeField(create_now=True)
31
31
  ```
32
32
 
33
+ - **Every model, not just some.** `postgres.Model` carries the transform, so the
34
+ checker builds each subclass's constructor out of its annotated attributes and
35
+ nothing else. An unannotated `name = types.TextField()` isn't a constructor
36
+ argument at all, and `Model(name="x")` is rejected as an unknown argument. The
37
+ runtime is unaffected — but a type-checked app has to annotate every model.
33
38
  - **Value type**: `Field[str]`, `Field[int]`, `Field[datetime]`; for an FK to a
34
39
  model class, `Field[RelatedModel]`.
35
40
  - **Optional in the constructor = a call-site `default=`.** A stock type checker
@@ -38,17 +43,38 @@ class Article(postgres.Model):
38
43
  `required=False` field with no `default=` is still a _required_ constructor arg.
39
44
  Add `default=` to any field you intend to omit when constructing.
40
45
  - **Nullable** (`allow_null=True`) → `Field[T | None]`, and add `default=None` so
41
- it's optional in the constructor (per the rule above). Applies to class-ref and
42
- string forward-ref FKs alike.
46
+ it's optional in the constructor (per the rule above). The runtime already
47
+ treats it as optional — constructing without it yields `None` — so
48
+ `default=None` is purely for the checker: it persists nothing and changes no
49
+ schema. `plain preflight` lists the ones still missing it
50
+ (`postgres.nullable_field_without_default`). Applies to class-ref and
51
+ string-ref FKs alike.
43
52
  - **DB-owned** fields are still annotated but auto-excluded from the
44
53
  constructor: the `id`, `create_now`/`update_now` datetimes, `generate=True`,
45
54
  and `RandomStringField`.
46
- - **String forward-ref FKs** (`"self"`, `"OtherModel"`) keep a _value-type_
47
- annotation — the checker can't resolve the string to a model:
48
- `parent: Foo | None = types.ForeignKeyField("self", on_delete=postgres.CASCADE, allow_null=True, default=None)`.
49
- That annotation is a model instance, not a field, so `where()` traversal
50
- (`Child.parent.name.equals(...)`) doesn't type-check through it — declare the
51
- target above and pass the class when you want traversal typed.
55
+ - **String model references** (`"users.User"`, `"OtherModel"`, `"self"`) annotate
56
+ `Field[Related]` like any other FK — the string is a runtime device for import
57
+ cycles, self-references, and cross-package references (a framework package
58
+ pointing at the app's `User`), not a typing compromise. `Related` comes from
59
+ the annotation, so import it under `TYPE_CHECKING` when importing it for real
60
+ would be the cycle you were avoiding:
61
+ ```python
62
+ if TYPE_CHECKING:
63
+ from app.users.models import User
64
+
65
+ user: Field[User] = types.ForeignKeyField("users.User", on_delete=postgres.CASCADE)
66
+ parent: Field[Foo | None] = types.ForeignKeyField(
67
+ "self", on_delete=postgres.CASCADE, allow_null=True, default=None
68
+ )
69
+ ```
70
+ Annotating the field with the related model itself (`user: User = ...`) names a
71
+ model instance rather than a field: class access becomes a `User`,
72
+ `Model.user.id` is an `int`, and the condition methods disappear.
73
+ `plain preflight`'s `postgres.foreign_key_annotated_as_value` reports a
74
+ foreign key whose annotation — on the model or any base class — names the
75
+ related model. It wants positive evidence, so an unannotated field, a
76
+ `ClassVar[...]` one, or a spelling it can't read stays quiet: a clean run
77
+ means "nothing found", not "nothing to find".
52
78
  - **Encrypted fields** are annotated `EncryptedField[T]` (imported from
53
79
  `plain.postgres` alongside `Field`), not `Field[T]`. It's a `Field[T]`
54
80
  subclass, so the constructor is typed identically, but it also carries the
@@ -103,6 +129,10 @@ Run `uv run plain docs postgres` for full workflow details.
103
129
  Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`).
104
130
 
105
131
  - `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site.
132
+ - `__isnull=False` converts to `is_null(False)` (`"x" IS NOT NULL`), not `~...is_null()` (`NOT ("x" IS NULL)`) — same rows, different SQL.
133
+ - A condition **is** a `Q`, so it goes anywhere a `Q` goes — `When(...)` (including the ones inside a `Case(...)`), `Count("id", filter=...)` — not just `where()`. `Case(...)` itself takes only `When` objects, never a bare condition.
134
+ - `where()` keeps conditions in the order written; `filter()` sorted its kwargs alphabetically. A converted multi-condition `filter()` can emit different WHERE text and parameter order (same rows; already-alphabetical kwargs convert unchanged) — matters for tests pinning SQL and for `pg_stat_statements`, which groups by predicate structure, not literal text.
135
+ - Conditions take the field's value type where string kwargs coerced: `Field[UUID].equals("...")` and `Field[int].equals("1")` are type errors. That is a type-checker guard only — runtime still coerces. Parse a CLI/URL/session string first (`uuid.UUID(raw)`, `int(raw)`); an invalid value raises `ValueError` there, before the ORM is involved.
106
136
  - **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Traversal only _starts_ from a forward FK; a many-to-many is traversable as a later hop (`WidgetTag.widget.tags.name`), but `Widget.tags` and reverse accessors are not entry points — use `filter(tags__name=...)` there.
107
137
  - Encrypted fields can't be looked up at all: `get_or_create(secret=...)` raises — put the value in `defaults=`.
108
138
  - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N