ferro-orm 0.16.3__tar.gz → 0.17.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 (383) hide show
  1. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/CHANGELOG.md +29 -0
  2. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/CONTEXT.md +4 -0
  3. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/Cargo.lock +28 -28
  4. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/Cargo.toml +1 -1
  5. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/PKG-INFO +1 -1
  6. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-schema-ir/src/lib.rs +263 -16
  7. ferro_orm-0.17.0/docs/adr/0007-reverse-relations-tested-not-traversed.md +62 -0
  8. ferro_orm-0.17.0/docs/adr/0008-predicate-negation-uniform-not.md +30 -0
  9. ferro_orm-0.17.0/docs/examples/existence_tests.py +195 -0
  10. ferro_orm-0.17.0/docs/examples/existence_tests_annotated.py +101 -0
  11. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/predicates.py +42 -0
  12. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/predicates_annotated.py +22 -0
  13. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/queries.md +4 -0
  14. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/queries.md +172 -4
  15. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/roadmap.md +2 -2
  16. ferro_orm-0.17.0/docs/plans/2026-07-18-016-existence-tests-design.md +161 -0
  17. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/pyproject.toml +1 -1
  18. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/columns.py +94 -0
  19. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/ir/compiler.py +5 -0
  20. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/models.py +4 -1
  21. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/query/builder.py +48 -9
  22. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/query/nodes.py +484 -24
  23. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/query/wire.py +14 -7
  24. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/operations.rs +147 -29
  25. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/query.rs +572 -4
  26. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/fixtures/ir_vectors/README.md +7 -5
  27. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_account_exists_v7.json +40 -0
  28. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json +74 -0
  29. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json +47 -0
  30. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_owner_not_exists_v7.json +37 -0
  31. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_aggregate_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_aggregate_v7.json +2 -2
  32. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v7.json +2 -2
  33. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_include_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_include_v7.json +2 -2
  34. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_left_join_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_left_join_v7.json +2 -2
  35. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_record_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_record_v7.json +2 -2
  36. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_traversal_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_traversal_v7.json +2 -2
  37. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_transaction_traversed_record_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_transaction_traversed_record_v7.json +2 -2
  38. ferro_orm-0.16.3/tests/fixtures/ir_vectors/query_user_compound_v5.json → ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_user_compound_v7.json +2 -2
  39. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_user_m2m_exists_v7.json +51 -0
  40. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_user_not_compound_v7.json +49 -0
  41. ferro_orm-0.17.0/tests/fixtures/ir_vectors/query_user_not_leaf_v7.json +44 -0
  42. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_ir_vectors_contract.py +58 -7
  43. ferro_orm-0.17.0/tests/test_negation.py +226 -0
  44. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_query_builder.py +1 -1
  45. ferro_orm-0.17.0/tests/test_query_exists.py +707 -0
  46. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_query_joins.py +13 -1
  47. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_query_wire_vectors.py +82 -10
  48. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  49. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  50. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/PERMISSIONS.md +0 -0
  51. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/PYPI_CHECKLIST.md +0 -0
  52. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/PYPI_SETUP.md +0 -0
  53. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/generated/wheels.generated.yml +0 -0
  54. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/pull_request_template.md +0 -0
  55. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/workflows/ci.yml +0 -0
  56. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/workflows/packaging-smoke.yml +0 -0
  57. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/workflows/publish-docs.yml +0 -0
  58. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/workflows/publish.yml +0 -0
  59. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.github/workflows/release.yml +0 -0
  60. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.gitignore +0 -0
  61. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.pre-commit-config.yaml +0 -0
  62. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/.python-version +0 -0
  63. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/AGENTS.md +0 -0
  64. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/CONTRIBUTING.md +0 -0
  65. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/LICENSE +0 -0
  66. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/README.md +0 -0
  67. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/README.md +0 -0
  68. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/__init__.py +0 -0
  69. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/__main__.py +0 -0
  70. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/backends.py +0 -0
  71. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/baselines/postgres.json +0 -0
  72. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/baselines/sqlite.json +0 -0
  73. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/compare.py +0 -0
  74. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/harness.py +0 -0
  75. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/model.py +0 -0
  76. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/benchmarks/run.py +0 -0
  77. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-ddl-lowering/Cargo.toml +0 -0
  78. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-ddl-lowering/src/lib.rs +0 -0
  79. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-migrate/Cargo.toml +0 -0
  80. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-migrate/src/emit.rs +0 -0
  81. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-migrate/src/lib.rs +0 -0
  82. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-migrate/src/order.rs +0 -0
  83. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-migrate/src/tests.rs +0 -0
  84. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/crates/ferro-schema-ir/Cargo.toml +0 -0
  85. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0001-resolved-epoch-registration.md +0 -0
  86. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0002-unified-column-fact-derivation.md +0 -0
  87. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0003-path-blind-db-type-validation.md +0 -0
  88. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0004-jsonb-postgres-only-canonical.md +0 -0
  89. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0005-jsonb-default-on-postgres.md +0 -0
  90. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0006-relation-traversal-inner-joins.md +0 -0
  91. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0007-materialization-plan-complete-instances.md +0 -0
  92. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0008-populated-relations-include.md +0 -0
  93. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/adr/0009-aggregate-projections-derived-grouping.md +0 -0
  94. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/agents/domain.md +0 -0
  95. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/agents/issue-tracker.md +0 -0
  96. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/agents/triage-labels.md +0 -0
  97. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/architecture-review-2026-07-08.html +0 -0
  98. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  99. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  100. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  101. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  102. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
  103. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-24-ir-p8-119-wire-automigrate-requirements.md +0 -0
  104. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-25-ir-p8-120-parity-gate-requirements.md +0 -0
  105. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-26-ir-p8.5-140-shared-lowering-requirements.md +0 -0
  106. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-26-ir-p8.5-143-db-type-drop-requirements.md +0 -0
  107. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-26-ir-p8.5-144-reconcile-indexes-requirements.md +0 -0
  108. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-28-ir-p8.5-141-single-schema-ir-producer-requirements.md +0 -0
  109. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-29-ir-p8.6-146-dialect-enum-unification-requirements.md +0 -0
  110. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-29-ir-p8.6-153-create-path-ir-requirements.md +0 -0
  111. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-30-ir-p8.6-155-deferred-annotations-requirements.md +0 -0
  112. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-06-30-ir-p8.6-158-check-renderer-requirements.md +0 -0
  113. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-07-01-ir-p8.6-154-datetime-tz-coarseness-requirements.md +0 -0
  114. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-07-01-ir-p8.6-162-typed-save-bind-requirements.md +0 -0
  115. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-07-01-ir-p8.6-165-blob-introspection-false-positive-requirements.md +0 -0
  116. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-07-02-ff-c1-codec-plan-design.md +0 -0
  117. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/brainstorms/2026-07-06-upgrade-guide-consolidation-requirements.md +0 -0
  118. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/aggregations.py +0 -0
  119. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/aggregations_annotated.py +0 -0
  120. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/multiple_databases.py +0 -0
  121. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/mutations.py +0 -0
  122. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/pagination.py +0 -0
  123. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/partial_selects.py +0 -0
  124. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/partial_selects_annotated.py +0 -0
  125. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/populated_relations.py +0 -0
  126. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/populated_relations_annotated.py +0 -0
  127. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/quickstart.py +0 -0
  128. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/quickstart_annotated.py +0 -0
  129. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/raw_sql.py +0 -0
  130. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/relationships.py +0 -0
  131. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/relationships_annotated.py +0 -0
  132. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/soft_deletes.py +0 -0
  133. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/soft_deletes_annotated.py +0 -0
  134. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/testing_conftest.py +0 -0
  135. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/timestamps.py +0 -0
  136. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/timestamps_annotated.py +0 -0
  137. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/transactions.py +0 -0
  138. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/traversal.py +0 -0
  139. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/examples/traversal_annotated.py +0 -0
  140. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/connection.md +0 -0
  141. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/exceptions.md +0 -0
  142. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/fields.md +0 -0
  143. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/migrations.md +0 -0
  144. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/model.md +0 -0
  145. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/raw-sql.md +0 -0
  146. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/relationships.md +0 -0
  147. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/api/transactions.md +0 -0
  148. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/changelog.md +0 -0
  149. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/concepts/architecture.md +0 -0
  150. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/concepts/backends.md +0 -0
  151. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/concepts/identity-map.md +0 -0
  152. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/concepts/performance.md +0 -0
  153. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/concepts/query-typing.md +0 -0
  154. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/concepts/type-safety.md +0 -0
  155. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/contributing.md +0 -0
  156. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/faq.md +0 -0
  157. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/getting-started/installation.md +0 -0
  158. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/getting-started/next-steps.md +0 -0
  159. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/getting-started/quickstart.md +0 -0
  160. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/aggregations.md +0 -0
  161. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/connections.md +0 -0
  162. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/migrations.md +0 -0
  163. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/models-and-fields.md +0 -0
  164. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/mutations.md +0 -0
  165. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/raw-sql.md +0 -0
  166. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/relationships.md +0 -0
  167. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/guide/transactions.md +0 -0
  168. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
  169. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/multiple-databases.md +0 -0
  170. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/pagination.md +0 -0
  171. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/soft-deletes.md +0 -0
  172. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/testing.md +0 -0
  173. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/timestamps.md +0 -0
  174. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/howto/upgrade-guide.md +0 -0
  175. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/index.md +0 -0
  176. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/stylesheets/extra.css +0 -0
  177. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/pages/why-ferro.md +0 -0
  178. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  179. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  180. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  181. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  182. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  183. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  184. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
  185. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
  186. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-24-001-feat-ir-p8-119-wire-automigrate-plan.md +0 -0
  187. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-25-001-feat-ir-p8-120-parity-gate-plan.md +0 -0
  188. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-26-001-feat-ir-p8.5-140-shared-lowering-plan.md +0 -0
  189. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-26-002-feat-ir-p8.5-143-db-type-drop-plan.md +0 -0
  190. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-26-003-feat-ir-p8.5-144-reconcile-indexes-plan.md +0 -0
  191. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-28-001-feat-ir-p8.5-141-single-schema-ir-producer-plan.md +0 -0
  192. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-29-001-refactor-ir-p8.6-153-create-path-unification-plan.md +0 -0
  193. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-29-002-refactor-ir-p8.6-146-dialect-enum-unification-plan.md +0 -0
  194. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-30-001-refactor-ir-p8.6-158-check-renderer-plan.md +0 -0
  195. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-06-30-002-fix-ir-p8.6-155-deferred-annotations-plan.md +0 -0
  196. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-01-001-feat-ir-p8.6-162-typed-save-bind-plan.md +0 -0
  197. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-01-002-fix-ir-p8.6-154-datetime-tz-coarseness-plan.md +0 -0
  198. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-01-003-fix-ir-p8.6-165-blob-introspection-plan.md +0 -0
  199. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-02-001-fable-fixes-roadmap.md +0 -0
  200. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-02-002-feat-ff-a-172-typed-exceptions-plan.md +0 -0
  201. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-02-003-ff-c-native-decode-design.md +0 -0
  202. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-02-004-ff-c-c2-catalog-cache-design.md +0 -0
  203. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-02-005-ff-d-identity-routing-design.md +0 -0
  204. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-02-006-ff-d-identity-routing-plan.md +0 -0
  205. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-03-007-ff-e-registry-identity-design.md +0 -0
  206. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-03-008-ff-e-registry-identity-plan.md +0 -0
  207. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-009-ff-f-query-builder-1.0-design.md +0 -0
  208. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-010-ff-f-query-builder-1.0-plan.md +0 -0
  209. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-011-ff-g-hardening-design.md +0 -0
  210. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-012-ff-g-hardening-plan.md +0 -0
  211. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-013-ff-g2-operations-dedup-design.md +0 -0
  212. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-014-ff-g2-operations-dedup-plan.md +0 -0
  213. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/2026-07-06-015-docs-upgrade-guide-consolidation-plan.md +0 -0
  214. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/ir-first-migration-guide.md +0 -0
  215. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/plans/ir-first-release-checklist.md +0 -0
  216. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/rfc/ir-contracts-v1.md +0 -0
  217. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/README.md +0 -0
  218. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/architecture-patterns/ir-first-lowering-consolidation-audit.md +0 -0
  219. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
  220. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  221. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  222. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  223. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  224. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/self-fk-evicts-component-from-create-order.md +0 -0
  225. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
  226. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  227. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  228. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  229. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  230. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
  231. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/derived-type-and-naming-decision-table.md +0 -0
  232. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  233. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  234. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/ir-invariants.md +0 -0
  235. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/persistence-state.md +0 -0
  236. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  237. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
  238. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
  239. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/justfile +0 -0
  240. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/scripts/demo_queries.py +0 -0
  241. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/scripts/demo_queries_pre_v012.py +0 -0
  242. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/backend.rs +0 -0
  243. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/codec.rs +0 -0
  244. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/codec_plan.rs +0 -0
  245. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/connection.rs +0 -0
  246. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/errors.rs +0 -0
  247. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/__init__.py +0 -0
  248. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/_annotation_utils.py +0 -0
  249. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/_bind_payload.py +0 -0
  250. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/_core.pyi +0 -0
  251. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/_deprecations.py +0 -0
  252. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/_shadow_fk_types.py +0 -0
  253. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/base.py +0 -0
  254. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/composite_indexes.py +0 -0
  255. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/composite_uniques.py +0 -0
  256. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/exceptions.py +0 -0
  257. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/fields.py +0 -0
  258. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/ir/__init__.py +0 -0
  259. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/metaclass.py +0 -0
  260. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/migrations/__init__.py +0 -0
  261. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/migrations/alembic.py +0 -0
  262. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/py.typed +0 -0
  263. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/query/__init__.py +0 -0
  264. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/query/rows.py +0 -0
  265. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/raw.py +0 -0
  266. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/registry.py +0 -0
  267. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/relations/__init__.py +0 -0
  268. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/relations/descriptors.py +0 -0
  269. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/session.py +0 -0
  270. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/ferro/state.py +0 -0
  271. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/hydration.rs +0 -0
  272. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/introspect.rs +0 -0
  273. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/lib.rs +0 -0
  274. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/migrate.rs +0 -0
  275. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/naming_ffi.rs +0 -0
  276. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/schema.rs +0 -0
  277. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/schema_bind.rs +0 -0
  278. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/src/state.rs +0 -0
  279. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/__init__.py +0 -0
  280. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/conftest.py +0 -0
  281. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/db_backends.py +0 -0
  282. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
  283. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
  284. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
  285. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/fixtures/ir_vectors/schema_raw_str_pk_autoincrement_v1.json +0 -0
  286. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/static_fixtures/bad_includes.py +0 -0
  287. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/static_fixtures/bad_predicates.py +0 -0
  288. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/static_fixtures/bad_projections.py +0 -0
  289. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/static_fixtures/good_includes.py +0 -0
  290. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/static_fixtures/good_predicates.py +0 -0
  291. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/static_fixtures/good_projections.py +0 -0
  292. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_aggregation.py +0 -0
  293. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_alembic_autogenerate.py +0 -0
  294. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_alembic_bridge.py +0 -0
  295. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_alembic_db_type.py +0 -0
  296. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_alembic_nullability.py +0 -0
  297. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_alembic_type_mapping.py +0 -0
  298. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_annotation_utils.py +0 -0
  299. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_auto_migrate.py +0 -0
  300. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_bulk_create_chunking.py +0 -0
  301. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_bulk_install.py +0 -0
  302. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_bulk_update.py +0 -0
  303. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_catalog_cache.py +0 -0
  304. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_codec_plan.py +0 -0
  305. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_column_specs.py +0 -0
  306. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_compiled_query.py +0 -0
  307. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_composite_index.py +0 -0
  308. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_composite_unique.py +0 -0
  309. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_connection.py +0 -0
  310. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_connection_redaction.py +0 -0
  311. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_constraints.py +0 -0
  312. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_cross_emitter_parity.py +0 -0
  313. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_crud.py +0 -0
  314. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_db_backends.py +0 -0
  315. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_db_type_cross_emitter_parity.py +0 -0
  316. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_db_type_integration.py +0 -0
  317. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_db_type_typing.py +0 -0
  318. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_db_type_validation.py +0 -0
  319. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_deletion.py +0 -0
  320. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_deprecations.py +0 -0
  321. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_docs_examples.py +0 -0
  322. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_documentation_features.py +0 -0
  323. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_enum_cold_hydration.py +0 -0
  324. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_exception_mapping.py +0 -0
  325. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_exceptions.py +0 -0
  326. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_field_wrapper.py +0 -0
  327. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_fk_target_pk.py +0 -0
  328. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_generation_counter_dirty_tracking.py +0 -0
  329. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_global_aggregates.py +0 -0
  330. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_grouped_aggregates.py +0 -0
  331. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_helpers.py +0 -0
  332. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_hydration.py +0 -0
  333. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_hydration_equivalence.py +0 -0
  334. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_identity_memory.py +0 -0
  335. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_identity_refresh.py +0 -0
  336. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_identity_scoped_invalidation.py +0 -0
  337. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_identity_weakref.py +0 -0
  338. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_import_budget.py +0 -0
  339. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_include.py +0 -0
  340. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_metaclass_internals.py +0 -0
  341. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_metadata.py +0 -0
  342. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_migrate_plan.py +0 -0
  343. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_model_identity.py +0 -0
  344. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_models.py +0 -0
  345. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_mutation_pagination_guard.py +0 -0
  346. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_named_connections_integration.py +0 -0
  347. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_naming_single_source.py +0 -0
  348. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_one_to_one.py +0 -0
  349. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_operation_seam_sync.py +0 -0
  350. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_partial_selects.py +0 -0
  351. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_primary_key_facts.py +0 -0
  352. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_provisional_import.py +0 -0
  353. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_query_column_validation.py +0 -0
  354. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_query_immutability.py +0 -0
  355. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_query_typing.py +0 -0
  356. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_raw_sql.py +0 -0
  357. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_refresh.py +0 -0
  358. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_registry_entrypoints.py +0 -0
  359. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_registry_owner.py +0 -0
  360. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_relation_specs.py +0 -0
  361. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_relationship_engine.py +0 -0
  362. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_relationship_resolution_errors.py +0 -0
  363. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_route_single_site.py +0 -0
  364. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_routing_errors.py +0 -0
  365. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_save_pk_edges.py +0 -0
  366. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_save_semantics.py +0 -0
  367. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_schema.py +0 -0
  368. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_schema_constraints.py +0 -0
  369. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_schema_db_type_metadata.py +0 -0
  370. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_schema_enum_annotations.py +0 -0
  371. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_session.py +0 -0
  372. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_shadow_fk_types.py +0 -0
  373. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  374. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_static_contracts.py +0 -0
  375. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_string_search.py +0 -0
  376. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_structural_types.py +0 -0
  377. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_temporal_types.py +0 -0
  378. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_transactions.py +0 -0
  379. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_traversed_projection.py +0 -0
  380. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_typed_null_binds.py +0 -0
  381. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/tests/test_typed_save_bind.py +0 -0
  382. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/uv.lock +0 -0
  383. {ferro_orm-0.16.3 → ferro_orm-0.17.0}/zensical.toml +0 -0
@@ -1,6 +1,35 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.17.0 (2026-07-18)
5
+
6
+ ### Documentation
7
+
8
+ - Design existence tests (.exists()) and uniform ~ negation
9
+ ([`c68a477`](https://github.com/syn54x/ferro-orm/commit/c68a47780cd5fe6d8ecfcb4f7e0f9c8ec2b798fe))
10
+
11
+ - **query**: Document universal ~ negation and three-valued logic
12
+ ([`6061d66`](https://github.com/syn54x/ferro-orm/commit/6061d66e96c99f089339f7a4a83b9424101da8be))
13
+
14
+ ### Features
15
+
16
+ - **query**: Bare .exists() on reverse FK — existence-test tracer bullet
17
+ ([#319](https://github.com/syn54x/ferro-orm/pull/319),
18
+ [`2538e06`](https://github.com/syn54x/ferro-orm/commit/2538e06b217e449d10dd564687f5ca3581d5d08d))
19
+
20
+ - **query**: Existence-test error surfaces and docs — the feature's edges
21
+ ([`be88aed`](https://github.com/syn54x/ferro-orm/commit/be88aed35f0975f9333bf0dabacaffc8a82f8f34))
22
+
23
+ - **query**: M2M existence tests — the two-hop correlation path
24
+ ([`336b529`](https://github.com/syn54x/ferro-orm/commit/336b5298d0e7beca8231452655544fcc7a15ad6a))
25
+
26
+ - **query**: Scoped existence tests — inner predicates, nesting, cross-scope guard
27
+ ([`df11625`](https://github.com/syn54x/ferro-orm/commit/df11625b90e01d293f4863bec8afafb9110a1ac3))
28
+
29
+ - **query**: Uniform predicate negation — prefix ~ as a NOT wire node
30
+ ([`bfa3126`](https://github.com/syn54x/ferro-orm/commit/bfa3126914b5a1b5d93a65ee3fe2f066bb314b1f))
31
+
32
+
4
33
  ## v0.16.3 (2026-07-16)
5
34
 
6
35
  ### Bug Fixes
@@ -56,6 +56,10 @@ _Avoid_: Join inference, nested filter, path lookup, string path
56
56
  The ordered sequence of forward-FK hops a traversal walks (`account`, or `account → owner`). A path is the identity of a join: the same path referenced anywhere in a query is one join, and distinct paths to the same model are distinct joins. Left-join requests apply to a whole path.
57
57
  _Avoid_: Join alias, lookup chain, dotted path string
58
58
 
59
+ **Existence test**:
60
+ The only predicate form on a reverse or many-to-many relation — `t.lines.exists(...)` — asking whether at least one related row exists, optionally scoped by a full inner predicate over the related model. Renders as a correlated EXISTS; the result stays root-shaped (shape-preserving query), so it composes with any other predicate, ordering, and paging. Reverse relations are *tested*, never *traversed*: traversal remains a forward-FK concept.
61
+ _Avoid_: membership filter, semi-join filter, reverse traversal, subquery filter, `.any()`
62
+
59
63
  **Shape-preserving query**:
60
64
  The invariant that filtering and ordering never change what a query returns — a query over Transaction yields Transaction instances regardless of which relations its predicates traverse. Only an explicit projection operation may change the result shape.
61
65
  _Avoid_: Implicit projection, row narrowing
@@ -327,7 +327,7 @@ dependencies = [
327
327
 
328
328
  [[package]]
329
329
  name = "ferro"
330
- version = "0.16.3"
330
+ version = "0.17.0"
331
331
  dependencies = [
332
332
  "chrono",
333
333
  "dashmap",
@@ -412,9 +412,9 @@ dependencies = [
412
412
 
413
413
  [[package]]
414
414
  name = "futures"
415
- version = "0.3.32"
415
+ version = "0.3.33"
416
416
  source = "registry+https://github.com/rust-lang/crates.io-index"
417
- checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
417
+ checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
418
418
  dependencies = [
419
419
  "futures-channel",
420
420
  "futures-core",
@@ -427,9 +427,9 @@ dependencies = [
427
427
 
428
428
  [[package]]
429
429
  name = "futures-channel"
430
- version = "0.3.32"
430
+ version = "0.3.33"
431
431
  source = "registry+https://github.com/rust-lang/crates.io-index"
432
- checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
432
+ checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
433
433
  dependencies = [
434
434
  "futures-core",
435
435
  "futures-sink",
@@ -437,15 +437,15 @@ dependencies = [
437
437
 
438
438
  [[package]]
439
439
  name = "futures-core"
440
- version = "0.3.32"
440
+ version = "0.3.33"
441
441
  source = "registry+https://github.com/rust-lang/crates.io-index"
442
- checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
442
+ checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
443
443
 
444
444
  [[package]]
445
445
  name = "futures-executor"
446
- version = "0.3.32"
446
+ version = "0.3.33"
447
447
  source = "registry+https://github.com/rust-lang/crates.io-index"
448
- checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
448
+ checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
449
449
  dependencies = [
450
450
  "futures-core",
451
451
  "futures-task",
@@ -465,15 +465,15 @@ dependencies = [
465
465
 
466
466
  [[package]]
467
467
  name = "futures-io"
468
- version = "0.3.32"
468
+ version = "0.3.33"
469
469
  source = "registry+https://github.com/rust-lang/crates.io-index"
470
- checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
470
+ checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
471
471
 
472
472
  [[package]]
473
473
  name = "futures-macro"
474
- version = "0.3.32"
474
+ version = "0.3.33"
475
475
  source = "registry+https://github.com/rust-lang/crates.io-index"
476
- checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
476
+ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
477
477
  dependencies = [
478
478
  "proc-macro2",
479
479
  "quote",
@@ -482,21 +482,21 @@ dependencies = [
482
482
 
483
483
  [[package]]
484
484
  name = "futures-sink"
485
- version = "0.3.32"
485
+ version = "0.3.33"
486
486
  source = "registry+https://github.com/rust-lang/crates.io-index"
487
- checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
487
+ checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
488
488
 
489
489
  [[package]]
490
490
  name = "futures-task"
491
- version = "0.3.32"
491
+ version = "0.3.33"
492
492
  source = "registry+https://github.com/rust-lang/crates.io-index"
493
- checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
493
+ checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
494
494
 
495
495
  [[package]]
496
496
  name = "futures-util"
497
- version = "0.3.32"
497
+ version = "0.3.33"
498
498
  source = "registry+https://github.com/rust-lang/crates.io-index"
499
- checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
499
+ checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
500
500
  dependencies = [
501
501
  "futures-channel",
502
502
  "futures-core",
@@ -1045,9 +1045,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
1045
1045
 
1046
1046
  [[package]]
1047
1047
  name = "portable-atomic"
1048
- version = "1.13.1"
1048
+ version = "1.14.0"
1049
1049
  source = "registry+https://github.com/rust-lang/crates.io-index"
1050
- checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
1050
+ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
1051
1051
 
1052
1052
  [[package]]
1053
1053
  name = "potential_utf"
@@ -1771,9 +1771,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1771
1771
 
1772
1772
  [[package]]
1773
1773
  name = "tokio"
1774
- version = "1.52.3"
1774
+ version = "1.53.0"
1775
1775
  source = "registry+https://github.com/rust-lang/crates.io-index"
1776
- checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
1776
+ checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
1777
1777
  dependencies = [
1778
1778
  "bytes",
1779
1779
  "libc",
@@ -1788,9 +1788,9 @@ dependencies = [
1788
1788
 
1789
1789
  [[package]]
1790
1790
  name = "tokio-macros"
1791
- version = "2.7.0"
1791
+ version = "2.7.1"
1792
1792
  source = "registry+https://github.com/rust-lang/crates.io-index"
1793
- checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
1793
+ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
1794
1794
  dependencies = [
1795
1795
  "proc-macro2",
1796
1796
  "quote",
@@ -1989,14 +1989,14 @@ version = "0.26.11"
1989
1989
  source = "registry+https://github.com/rust-lang/crates.io-index"
1990
1990
  checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
1991
1991
  dependencies = [
1992
- "webpki-roots 1.0.8",
1992
+ "webpki-roots 1.0.9",
1993
1993
  ]
1994
1994
 
1995
1995
  [[package]]
1996
1996
  name = "webpki-roots"
1997
- version = "1.0.8"
1997
+ version = "1.0.9"
1998
1998
  source = "registry+https://github.com/rust-lang/crates.io-index"
1999
- checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
1999
+ checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
2000
2000
  dependencies = [
2001
2001
  "rustls-pki-types",
2002
2002
  ]
@@ -3,7 +3,7 @@ members = ["crates/ferro-schema-ir", "crates/ferro-ddl-lowering", "crates/ferro-
3
3
 
4
4
  [package]
5
5
  name = "ferro"
6
- version = "0.16.3"
6
+ version = "0.17.0"
7
7
  edition = "2024"
8
8
  readme = "README.md"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.16.3
3
+ Version: 0.17.0
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -137,13 +137,15 @@ pub struct SchemaCheck {
137
137
  /// Query IR: filter, sort, pagination, joins, materialization plan, and
138
138
  /// optional M2M join context.
139
139
  ///
140
- /// `ir_version: 5` (unconditional, no earlier version emitted anywhere —
141
- /// #269, #278, #285, #292). `joins` is required and always present (`[]` when
142
- /// the query traverses no relation); every leaf and `order_by` entry carries a
143
- /// `path` (required, `[]` = root model); `materialization` is required — the
144
- /// plan travels with the query as data (ADR-0007), never inferred from a
145
- /// column list. v5 gives `record` fields expression sources: a field carries
146
- /// either a `column` + `path` or an `expr` (#292, ADR-0009).
140
+ /// `ir_version: 7` (unconditional, no earlier version emitted anywhere —
141
+ /// #269, #278, #285, #292, #310, #314). `joins` is required and always
142
+ /// present (`[]` when the query traverses no relation); every leaf and
143
+ /// `order_by` entry carries a `path` (required, `[]` = root model);
144
+ /// `materialization` is required — the plan travels with the query as data
145
+ /// (ADR-0007), never inferred from a column list. v5 gave `record` fields
146
+ /// expression sources (#292, ADR-0009); v6 gave predicate trees the
147
+ /// recursive `not` node kind (uniform negation, #310, ADR-0008); v7 adds the
148
+ /// recursive `exists` node kind (existence tests, #314, ADR-0007).
147
149
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148
150
  pub struct QueryIrPayload {
149
151
  /// Model class name the query targets.
@@ -333,7 +335,9 @@ pub struct QueryOrderBy {
333
335
  pub path: Vec<String>,
334
336
  }
335
337
 
336
- /// Predicate tree node in query IR (leaf comparison or compound AND/OR).
338
+ /// Predicate tree node in query IR: leaf comparison, compound AND/OR, a
339
+ /// recursive NOT wrapping any child (uniform negation, v6, ADR-0008), or a
340
+ /// recursive existence test over a reverse/M2M relation (v7, ADR-0007).
337
341
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
338
342
  #[serde(tag = "node_kind")]
339
343
  pub enum QueryNode {
@@ -360,6 +364,37 @@ pub enum QueryNode {
360
364
  /// Right subtree.
361
365
  right: Box<QueryNode>,
362
366
  },
367
+ /// Uniform negation (`~`, ADR-0008): SQL `NOT (...)` over the child.
368
+ /// Fully general — the child may be any node kind, at any depth; there
369
+ /// are no per-operator negative forms on the wire.
370
+ #[serde(rename = "not")]
371
+ Not {
372
+ /// The negated subtree.
373
+ child: Box<QueryNode>,
374
+ },
375
+ /// Existence test on a reverse or M2M relation (ADR-0007): a correlated
376
+ /// `EXISTS (SELECT 1 ...)` at every cardinality. Carries no negation
377
+ /// flag — NOT EXISTS is the `not` node over this one.
378
+ #[serde(rename = "exists")]
379
+ Exists {
380
+ /// Correlation hop path in the `joins`-section hop-fact shape: one
381
+ /// hop for a reverse FK (`FROM child WHERE child.fk = root.pk`), two
382
+ /// for M2M (join table, then target). The first hop's table is the
383
+ /// subquery FROM, correlated to the enclosing scope's alias;
384
+ /// remaining hops render as inner joins inside the subquery.
385
+ hops: Vec<QueryJoinHop>,
386
+ /// Ordinary inner condition tree over the relation's target model
387
+ /// (`[]` = bare test). May itself contain `exists`/`not` nodes —
388
+ /// nesting is recursion, not a second mechanism.
389
+ #[serde(rename = "where")]
390
+ where_clause: Vec<QueryNode>,
391
+ /// Forward-traversal joins the inner tree references (#315),
392
+ /// rendered INSIDE the subquery as inner joins (ADR-0006 traversal
393
+ /// semantics are unchanged there; `left_join` has no inner-lambda
394
+ /// spelling). Absent on the wire when empty — pinned bytes.
395
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
396
+ joins: Vec<QueryJoin>,
397
+ },
363
398
  }
364
399
 
365
400
  /// Typed literal or parameter value on the RHS of a leaf predicate.
@@ -438,7 +473,7 @@ mod tests {
438
473
  #[test]
439
474
  fn query_fixture_roundtrip() {
440
475
  let fixture =
441
- include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v5.json");
476
+ include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v7.json");
442
477
  let parsed: serde_json::Value =
443
478
  serde_json::from_str(fixture).expect("query fixture must parse");
444
479
  let ir = parsed
@@ -456,12 +491,224 @@ mod tests {
456
491
  assert_eq!(encoded, ir, "query round-trip must not drift");
457
492
  }
458
493
 
494
+ #[test]
495
+ fn query_not_leaf_fixture_roundtrip() {
496
+ // The not-over-leaf golden vector (#312, ADR-0008): uniform negation
497
+ // travels as one recursive `not` node wrapping its child — here a
498
+ // leaf `IN` comparison (the NOT IN spelling) — and must survive a
499
+ // deserialize/serialize round-trip without drift.
500
+ let fixture =
501
+ include_str!("../../../tests/fixtures/ir_vectors/query_user_not_leaf_v7.json");
502
+ let parsed: serde_json::Value =
503
+ serde_json::from_str(fixture).expect("query not-leaf fixture must parse");
504
+ let ir = parsed
505
+ .get("ir")
506
+ .cloned()
507
+ .expect("fixture must contain ir envelope");
508
+ let envelope: IrEnvelope<QueryIrPayload> =
509
+ serde_json::from_value(ir.clone()).expect("query not-leaf IR must deserialize");
510
+ // The wire carries a `not` node whose child is the IN leaf.
511
+ match &envelope.payload.where_clause[0] {
512
+ QueryNode::Not { child } => match child.as_ref() {
513
+ QueryNode::Leaf { operator, .. } => assert_eq!(operator, "IN"),
514
+ other => panic!("not child must be the IN leaf, got {other:?}"),
515
+ },
516
+ other => panic!("where[0] must be a not node, got {other:?}"),
517
+ }
518
+ let encoded = serde_json::to_value(&envelope).expect("query not-leaf IR must serialize");
519
+ assert_eq!(encoded, ir, "query not-leaf round-trip must not drift");
520
+ }
521
+
522
+ #[test]
523
+ fn query_not_compound_fixture_roundtrip() {
524
+ // The not-over-compound golden vector (#313, ADR-0008): `~` wraps an
525
+ // OR compound whole — no De Morgan expansion on the wire — and must
526
+ // survive a deserialize/serialize round-trip without drift.
527
+ let fixture =
528
+ include_str!("../../../tests/fixtures/ir_vectors/query_user_not_compound_v7.json");
529
+ let parsed: serde_json::Value =
530
+ serde_json::from_str(fixture).expect("query not-compound fixture must parse");
531
+ let ir = parsed
532
+ .get("ir")
533
+ .cloned()
534
+ .expect("fixture must contain ir envelope");
535
+ let envelope: IrEnvelope<QueryIrPayload> =
536
+ serde_json::from_value(ir.clone()).expect("query not-compound IR must deserialize");
537
+ match &envelope.payload.where_clause[0] {
538
+ QueryNode::Not { child } => match child.as_ref() {
539
+ QueryNode::Compound { operator, .. } => assert_eq!(operator, "OR"),
540
+ other => panic!("not child must be the OR compound, got {other:?}"),
541
+ },
542
+ other => panic!("where[0] must be a not node, got {other:?}"),
543
+ }
544
+ let encoded =
545
+ serde_json::to_value(&envelope).expect("query not-compound IR must serialize");
546
+ assert_eq!(encoded, ir, "query not-compound round-trip must not drift");
547
+ }
548
+
549
+ #[test]
550
+ fn query_exists_fixture_roundtrip() {
551
+ // The bare 1-hop existence-test golden vector (#314, ADR-0007): an
552
+ // `exists` node carries its correlation hop path plus an empty inner
553
+ // condition tree, and must survive a deserialize/serialize
554
+ // round-trip without drift.
555
+ let fixture =
556
+ include_str!("../../../tests/fixtures/ir_vectors/query_account_exists_v7.json");
557
+ let parsed: serde_json::Value =
558
+ serde_json::from_str(fixture).expect("query exists fixture must parse");
559
+ let ir = parsed
560
+ .get("ir")
561
+ .cloned()
562
+ .expect("fixture must contain ir envelope");
563
+ let envelope: IrEnvelope<QueryIrPayload> =
564
+ serde_json::from_value(ir.clone()).expect("query exists IR must deserialize");
565
+ match &envelope.payload.where_clause[0] {
566
+ QueryNode::Exists {
567
+ hops, where_clause, ..
568
+ } => {
569
+ assert_eq!(hops.len(), 1);
570
+ assert_eq!(hops[0].relation, "transactions");
571
+ assert_eq!(hops[0].from_column, "id");
572
+ assert_eq!(hops[0].to_table, "transaction");
573
+ assert_eq!(hops[0].to_column, "account_id");
574
+ assert!(where_clause.is_empty(), "bare test carries no inner tree");
575
+ }
576
+ other => panic!("where[0] must be an exists node, got {other:?}"),
577
+ }
578
+ let encoded = serde_json::to_value(&envelope).expect("query exists IR must serialize");
579
+ assert_eq!(encoded, ir, "query exists round-trip must not drift");
580
+ }
581
+
582
+ #[test]
583
+ fn query_not_exists_fixture_roundtrip() {
584
+ // NOT EXISTS on the wire is the ordinary `not` node over an `exists`
585
+ // node — the exists node carries no negation flag (ADR-0008
586
+ // composition; #314).
587
+ let fixture =
588
+ include_str!("../../../tests/fixtures/ir_vectors/query_owner_not_exists_v7.json");
589
+ let parsed: serde_json::Value =
590
+ serde_json::from_str(fixture).expect("query not-exists fixture must parse");
591
+ let ir = parsed
592
+ .get("ir")
593
+ .cloned()
594
+ .expect("fixture must contain ir envelope");
595
+ let envelope: IrEnvelope<QueryIrPayload> =
596
+ serde_json::from_value(ir.clone()).expect("query not-exists IR must deserialize");
597
+ match &envelope.payload.where_clause[0] {
598
+ QueryNode::Not { child } => match child.as_ref() {
599
+ QueryNode::Exists { hops, .. } => assert_eq!(hops[0].relation, "accounts"),
600
+ other => panic!("not child must be the exists node, got {other:?}"),
601
+ },
602
+ other => panic!("where[0] must be a not node, got {other:?}"),
603
+ }
604
+ let encoded = serde_json::to_value(&envelope).expect("query not-exists IR must serialize");
605
+ assert_eq!(encoded, ir, "query not-exists round-trip must not drift");
606
+ }
607
+
608
+ #[test]
609
+ fn query_scoped_exists_fixture_roundtrip() {
610
+ // The scoped existence-test golden vector (#315): the inner tree is
611
+ // an ordinary condition tree, and a traversed inner leaf's hop facts
612
+ // ride the exists node's own `joins` section (rendered INSIDE the
613
+ // subquery). Must survive a deserialize/serialize round-trip.
614
+ let fixture =
615
+ include_str!("../../../tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json");
616
+ let parsed: serde_json::Value =
617
+ serde_json::from_str(fixture).expect("query scoped-exists fixture must parse");
618
+ let ir = parsed
619
+ .get("ir")
620
+ .cloned()
621
+ .expect("fixture must contain ir envelope");
622
+ let envelope: IrEnvelope<QueryIrPayload> =
623
+ serde_json::from_value(ir.clone()).expect("query scoped-exists IR must deserialize");
624
+ match &envelope.payload.where_clause[0] {
625
+ QueryNode::Exists {
626
+ hops,
627
+ where_clause,
628
+ joins,
629
+ } => {
630
+ assert_eq!(hops.len(), 1);
631
+ assert_eq!(where_clause.len(), 1);
632
+ assert_eq!(joins.len(), 1);
633
+ assert_eq!(joins[0].join_type, "inner");
634
+ assert_eq!(joins[0].path[0].relation, "account");
635
+ }
636
+ other => panic!("where[0] must be an exists node, got {other:?}"),
637
+ }
638
+ let encoded =
639
+ serde_json::to_value(&envelope).expect("query scoped-exists IR must serialize");
640
+ assert_eq!(encoded, ir, "query scoped-exists round-trip must not drift");
641
+ }
642
+
643
+ #[test]
644
+ fn query_nested_exists_fixture_roundtrip() {
645
+ // Nested exists-in-exists (#315): depth is recursion, not a second
646
+ // mechanism, and the bare inner node omits `joins` entirely (absent,
647
+ // not empty — pinned wire bytes via skip_serializing_if).
648
+ let fixture =
649
+ include_str!("../../../tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json");
650
+ let parsed: serde_json::Value =
651
+ serde_json::from_str(fixture).expect("query nested-exists fixture must parse");
652
+ let ir = parsed
653
+ .get("ir")
654
+ .cloned()
655
+ .expect("fixture must contain ir envelope");
656
+ let envelope: IrEnvelope<QueryIrPayload> =
657
+ serde_json::from_value(ir.clone()).expect("query nested-exists IR must deserialize");
658
+ match &envelope.payload.where_clause[0] {
659
+ QueryNode::Exists { where_clause, .. } => match &where_clause[0] {
660
+ QueryNode::Exists { hops, joins, .. } => {
661
+ assert_eq!(hops[0].relation, "transactions");
662
+ assert!(joins.is_empty(), "bare nested test carries no joins");
663
+ }
664
+ other => panic!("inner where[0] must be an exists node, got {other:?}"),
665
+ },
666
+ other => panic!("where[0] must be an exists node, got {other:?}"),
667
+ }
668
+ let encoded =
669
+ serde_json::to_value(&envelope).expect("query nested-exists IR must serialize");
670
+ assert_eq!(encoded, ir, "query nested-exists round-trip must not drift");
671
+ }
672
+
673
+ #[test]
674
+ fn query_m2m_exists_fixture_roundtrip() {
675
+ // The two-hop M2M existence-test golden vector (#316): the SAME
676
+ // exists node, a two-hop correlation path — join table first, then
677
+ // the target — with the scoped inner tree over the target model.
678
+ // Must survive a deserialize/serialize round-trip without drift.
679
+ let fixture =
680
+ include_str!("../../../tests/fixtures/ir_vectors/query_user_m2m_exists_v7.json");
681
+ let parsed: serde_json::Value =
682
+ serde_json::from_str(fixture).expect("query m2m-exists fixture must parse");
683
+ let ir = parsed
684
+ .get("ir")
685
+ .cloned()
686
+ .expect("fixture must contain ir envelope");
687
+ let envelope: IrEnvelope<QueryIrPayload> =
688
+ serde_json::from_value(ir.clone()).expect("query m2m-exists IR must deserialize");
689
+ match &envelope.payload.where_clause[0] {
690
+ QueryNode::Exists {
691
+ hops, where_clause, ..
692
+ } => {
693
+ assert_eq!(hops.len(), 2, "M2M correlates through two hops");
694
+ assert_eq!(hops[0].to_table, "tag_users");
695
+ assert_eq!(hops[0].to_column, "user_id");
696
+ assert_eq!(hops[1].from_column, "tag_id");
697
+ assert_eq!(hops[1].to_table, "tag");
698
+ assert_eq!(where_clause.len(), 1);
699
+ }
700
+ other => panic!("where[0] must be an exists node, got {other:?}"),
701
+ }
702
+ let encoded = serde_json::to_value(&envelope).expect("query m2m-exists IR must serialize");
703
+ assert_eq!(encoded, ir, "query m2m-exists round-trip must not drift");
704
+ }
705
+
459
706
  #[test]
460
707
  fn query_traversal_fixture_roundtrip() {
461
708
  // Multi-hop `joins` section + path-carrying leaves must survive a
462
709
  // deserialize/serialize round-trip without drift (#270 wire stability).
463
710
  let fixture = include_str!(
464
- "../../../tests/fixtures/ir_vectors/query_transaction_traversal_v5.json"
711
+ "../../../tests/fixtures/ir_vectors/query_transaction_traversal_v7.json"
465
712
  );
466
713
  let parsed: serde_json::Value =
467
714
  serde_json::from_str(fixture).expect("query traversal fixture must parse");
@@ -487,7 +734,7 @@ mod tests {
487
734
  // deserialize/serialize round-trip without drift, and the join_type
488
735
  // tokens must reach Rust exactly as written on the wire.
489
736
  let fixture =
490
- include_str!("../../../tests/fixtures/ir_vectors/query_transaction_left_join_v5.json");
737
+ include_str!("../../../tests/fixtures/ir_vectors/query_transaction_left_join_v7.json");
491
738
  let parsed: serde_json::Value =
492
739
  serde_json::from_str(fixture).expect("query left_join fixture must parse");
493
740
  let ir = parsed
@@ -513,7 +760,7 @@ mod tests {
513
760
  // payload — predicate, order, limit, and a two-field record plan —
514
761
  // survives a deserialize/serialize round-trip without drift.
515
762
  let fixture =
516
- include_str!("../../../tests/fixtures/ir_vectors/query_transaction_record_v5.json");
763
+ include_str!("../../../tests/fixtures/ir_vectors/query_transaction_record_v7.json");
517
764
  let parsed: serde_json::Value =
518
765
  serde_json::from_str(fixture).expect("query record fixture must parse");
519
766
  let ir = parsed
@@ -712,7 +959,7 @@ mod tests {
712
959
  // identity) — survives a deserialize/serialize round-trip without
713
960
  // drift. No group keys: the whole result collapses to one record.
714
961
  let fixture = include_str!(
715
- "../../../tests/fixtures/ir_vectors/query_transaction_global_aggregate_v5.json"
962
+ "../../../tests/fixtures/ir_vectors/query_transaction_global_aggregate_v7.json"
716
963
  );
717
964
  let parsed: serde_json::Value =
718
965
  serde_json::from_str(fixture).expect("global aggregate fixture must parse");
@@ -750,7 +997,7 @@ mod tests {
750
997
  // joins section included — survives a deserialize/serialize
751
998
  // round-trip without drift.
752
999
  let fixture = include_str!(
753
- "../../../tests/fixtures/ir_vectors/query_transaction_traversed_record_v5.json"
1000
+ "../../../tests/fixtures/ir_vectors/query_transaction_traversed_record_v7.json"
754
1001
  );
755
1002
  let parsed: serde_json::Value =
756
1003
  serde_json::from_str(fixture).expect("query traversed record fixture must parse");
@@ -788,7 +1035,7 @@ mod tests {
788
1035
  // a deserialize/serialize round-trip without drift. GROUP BY does not
789
1036
  // travel: the renderer derives it from the non-expr fields (ADR-0009).
790
1037
  let fixture = include_str!(
791
- "../../../tests/fixtures/ir_vectors/query_transaction_aggregate_v5.json"
1038
+ "../../../tests/fixtures/ir_vectors/query_transaction_aggregate_v7.json"
792
1039
  );
793
1040
  let parsed: serde_json::Value =
794
1041
  serde_json::from_str(fixture).expect("query aggregate fixture must parse");
@@ -856,7 +1103,7 @@ mod tests {
856
1103
  // payload — root predicate, empty `joins`, and a one-path instances
857
1104
  // plan — survives a deserialize/serialize round-trip without drift.
858
1105
  let fixture =
859
- include_str!("../../../tests/fixtures/ir_vectors/query_transaction_include_v5.json");
1106
+ include_str!("../../../tests/fixtures/ir_vectors/query_transaction_include_v7.json");
860
1107
  let parsed: serde_json::Value =
861
1108
  serde_json::from_str(fixture).expect("query include fixture must parse");
862
1109
  let ir = parsed
@@ -0,0 +1,62 @@
1
+ # Reverse relations are tested, not traversed
2
+
3
+ A reverse (BackRef) or many-to-many relation appears in a query predicate in
4
+ exactly one form: the **existence test** — `t.lines.exists(...)`, optionally
5
+ scoped by an inner predicate over the related model, negated with `~`. It
6
+ renders as a correlated `EXISTS` subquery at every cardinality (one-to-one
7
+ BackRefs included) and for every relation kind (reverse FK and M2M, the latter
8
+ correlating through the join table). The result stays root-shaped: an
9
+ existence test never multiplies root rows and composes with any other
10
+ predicate, ordering, and paging.
11
+
12
+ The proxy for a reverse relation exposes `.exists()` and nothing else. Column
13
+ access (`t.lines.category_id`), comparison (`t.transfer_out != None`), and
14
+ `in_` all raise at build time with the supported spelling in the message.
15
+ There is deliberately no implicit path traversal onto reverse edges — the
16
+ spelling sketched by the originating external PRDs (#307/#308, from Pinch).
17
+ Implicit traversal has an unanswerable grouping ambiguity: in
18
+ `(t.lines.a == 1) & (t.lines.b == 2)` nothing visible says whether one child
19
+ row must match both conditions or some child may match each. Django chose
20
+ implicit and the resulting confusion is a documented hazard of that API. The
21
+ explicit combinator makes grouping the user's choice —
22
+ `t.lines.exists(lambda l: A & B)` versus
23
+ `t.lines.exists(A-test) & t.lines.exists(B-test)` — both spellable, both
24
+ unambiguous. This is the same determinism-over-friendliness ground on which
25
+ ADR-0006 rejected auto-LEFT.
26
+
27
+ The inner predicate is a full ferro predicate over the related model: same
28
+ operators, forward traversal (rendered as joins inside the subquery, ADR-0006
29
+ semantics unchanged), and nested existence tests to arbitrary depth. What it
30
+ may not do (v1) is reference any scope other than its own parameter —
31
+ cross-scope column comparison is rejected at build time and tracked as a
32
+ possible future extension.
33
+
34
+ Rejected alternatives:
35
+
36
+ - **`.any()`/`.has()` cardinality split (SQLAlchemy)** — two names for one
37
+ mechanism, and call sites break when a FK's `unique=` changes. One concept,
38
+ one verb: `.exists()` at every cardinality.
39
+ - **`in_(single-column ProjectedQuery)`** — declined, not deferred. The
40
+ workloads it serves are existence-test workloads; it forces callers to
41
+ hand-correlate on id columns and leaks the join column into every call site.
42
+ - **LEFT JOIN + IS NOT NULL specialization for unique BackRefs** — safe but a
43
+ second render path with no user-visible benefit; planners already rewrite
44
+ correlated EXISTS into semi-joins. May become an invisible planner-side
45
+ optimization later if measured.
46
+ - **`!= None` / `== None` sugar for bare existence** — forward `== None`
47
+ desugars to IS NULL on a shadow column that physically lives on the root
48
+ table (ADR-0006); a reverse relation has no root-side column, so the sugar
49
+ would be silent implicit traversal.
50
+
51
+ ## Consequences
52
+
53
+ - The glossary term "Relation traversal" stays forward-FK-only, and that
54
+ narrowness is now load-bearing rather than incidental.
55
+ - `include()` (population) and `.exists()` (membership) remain distinct axes;
56
+ BackRef population is still a separate future mechanism.
57
+ - The wire IR gains one recursive node kind for the existence test, carrying a
58
+ correlation hop path (1 hop for reverse FK, 2 for M2M) — the Rust renderer
59
+ handles both with one loop, so M2M support is test surface, not mechanism.
60
+ - `left_join()` on a reverse edge stays rejected (it would reopen the
61
+ "join never multiplies root rows" property) but the error now names
62
+ `.exists()` as the supported spelling.