ferro-orm 0.15.0__tar.gz → 0.16.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 (361) hide show
  1. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/CHANGELOG.md +31 -0
  2. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/CONTEXT.md +12 -0
  3. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/Cargo.lock +5 -5
  4. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/Cargo.toml +1 -1
  5. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/PKG-INFO +1 -1
  6. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-schema-ir/src/lib.rs +361 -23
  7. ferro_orm-0.16.0/docs/adr/0009-aggregate-projections-derived-grouping.md +70 -0
  8. ferro_orm-0.16.0/docs/examples/aggregations.py +259 -0
  9. ferro_orm-0.16.0/docs/examples/aggregations_annotated.py +52 -0
  10. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/partial_selects.py +75 -2
  11. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/partial_selects_annotated.py +10 -1
  12. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/populated_relations.py +1 -1
  13. ferro_orm-0.16.0/docs/pages/api/queries.md +46 -0
  14. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/query-typing.md +27 -1
  15. ferro_orm-0.16.0/docs/pages/guide/aggregations.md +162 -0
  16. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/queries.md +44 -8
  17. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/raw-sql.md +1 -1
  18. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/migrate-from-sqlalchemy.md +4 -4
  19. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/roadmap.md +2 -1
  20. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/why-ferro.md +1 -1
  21. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/pyproject.toml +1 -1
  22. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/__init__.py +2 -0
  23. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/builder.py +518 -124
  24. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/nodes.py +191 -8
  25. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/operations.rs +941 -132
  26. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/query.rs +14 -0
  27. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/README.md +5 -4
  28. ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_aggregate_v5.json +82 -0
  29. ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v5.json +80 -0
  30. ferro_orm-0.15.0/tests/fixtures/ir_vectors/query_transaction_include_v4.json → ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_include_v5.json +2 -2
  31. ferro_orm-0.15.0/tests/fixtures/ir_vectors/query_transaction_left_join_v4.json → ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_left_join_v5.json +2 -2
  32. ferro_orm-0.15.0/tests/fixtures/ir_vectors/query_transaction_record_v4.json → ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_record_v5.json +2 -2
  33. ferro_orm-0.15.0/tests/fixtures/ir_vectors/query_transaction_traversal_v4.json → ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_traversal_v5.json +2 -2
  34. ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_traversed_record_v5.json +84 -0
  35. ferro_orm-0.15.0/tests/fixtures/ir_vectors/query_user_compound_v4.json → ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_user_compound_v5.json +2 -2
  36. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/bad_projections.py +10 -0
  37. ferro_orm-0.16.0/tests/static_fixtures/good_projections.py +109 -0
  38. ferro_orm-0.16.0/tests/test_global_aggregates.py +374 -0
  39. ferro_orm-0.16.0/tests/test_grouped_aggregates.py +406 -0
  40. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_include.py +10 -2
  41. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_ir_vectors_contract.py +41 -9
  42. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_partial_selects.py +4 -6
  43. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_builder.py +1 -1
  44. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_static_contracts.py +10 -0
  45. ferro_orm-0.16.0/tests/test_traversed_projection.py +427 -0
  46. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/uv.lock +1 -1
  47. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/zensical.toml +1 -0
  48. ferro_orm-0.15.0/docs/pages/api/queries.md +0 -37
  49. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  50. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  51. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/PERMISSIONS.md +0 -0
  52. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/PYPI_CHECKLIST.md +0 -0
  53. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/PYPI_SETUP.md +0 -0
  54. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/generated/wheels.generated.yml +0 -0
  55. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/pull_request_template.md +0 -0
  56. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/ci.yml +0 -0
  57. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/packaging-smoke.yml +0 -0
  58. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/publish-docs.yml +0 -0
  59. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/publish.yml +0 -0
  60. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/release.yml +0 -0
  61. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.gitignore +0 -0
  62. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.pre-commit-config.yaml +0 -0
  63. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.python-version +0 -0
  64. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/AGENTS.md +0 -0
  65. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/CONTRIBUTING.md +0 -0
  66. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/LICENSE +0 -0
  67. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/README.md +0 -0
  68. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/README.md +0 -0
  69. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/__init__.py +0 -0
  70. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/__main__.py +0 -0
  71. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/backends.py +0 -0
  72. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/baselines/postgres.json +0 -0
  73. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/baselines/sqlite.json +0 -0
  74. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/compare.py +0 -0
  75. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/harness.py +0 -0
  76. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/model.py +0 -0
  77. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/run.py +0 -0
  78. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-ddl-lowering/Cargo.toml +0 -0
  79. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-ddl-lowering/src/lib.rs +0 -0
  80. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/Cargo.toml +0 -0
  81. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/src/emit.rs +0 -0
  82. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/src/lib.rs +0 -0
  83. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/src/tests.rs +0 -0
  84. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-schema-ir/Cargo.toml +0 -0
  85. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0001-resolved-epoch-registration.md +0 -0
  86. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0002-unified-column-fact-derivation.md +0 -0
  87. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0003-path-blind-db-type-validation.md +0 -0
  88. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0004-jsonb-postgres-only-canonical.md +0 -0
  89. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0005-jsonb-default-on-postgres.md +0 -0
  90. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0006-relation-traversal-inner-joins.md +0 -0
  91. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0007-materialization-plan-complete-instances.md +0 -0
  92. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0008-populated-relations-include.md +0 -0
  93. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/agents/domain.md +0 -0
  94. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/agents/issue-tracker.md +0 -0
  95. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/agents/triage-labels.md +0 -0
  96. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/architecture-review-2026-07-08.html +0 -0
  97. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  98. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  99. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  100. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  101. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
  102. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-24-ir-p8-119-wire-automigrate-requirements.md +0 -0
  103. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-25-ir-p8-120-parity-gate-requirements.md +0 -0
  104. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-26-ir-p8.5-140-shared-lowering-requirements.md +0 -0
  105. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-26-ir-p8.5-143-db-type-drop-requirements.md +0 -0
  106. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-26-ir-p8.5-144-reconcile-indexes-requirements.md +0 -0
  107. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-28-ir-p8.5-141-single-schema-ir-producer-requirements.md +0 -0
  108. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-29-ir-p8.6-146-dialect-enum-unification-requirements.md +0 -0
  109. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-29-ir-p8.6-153-create-path-ir-requirements.md +0 -0
  110. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-30-ir-p8.6-155-deferred-annotations-requirements.md +0 -0
  111. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-30-ir-p8.6-158-check-renderer-requirements.md +0 -0
  112. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-01-ir-p8.6-154-datetime-tz-coarseness-requirements.md +0 -0
  113. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-01-ir-p8.6-162-typed-save-bind-requirements.md +0 -0
  114. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-01-ir-p8.6-165-blob-introspection-false-positive-requirements.md +0 -0
  115. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-02-ff-c1-codec-plan-design.md +0 -0
  116. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-06-upgrade-guide-consolidation-requirements.md +0 -0
  117. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/multiple_databases.py +0 -0
  118. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/mutations.py +0 -0
  119. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/pagination.py +0 -0
  120. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/populated_relations_annotated.py +0 -0
  121. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/predicates.py +0 -0
  122. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/predicates_annotated.py +0 -0
  123. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/quickstart.py +0 -0
  124. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/quickstart_annotated.py +0 -0
  125. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/raw_sql.py +0 -0
  126. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/relationships.py +0 -0
  127. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/relationships_annotated.py +0 -0
  128. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/soft_deletes.py +0 -0
  129. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/soft_deletes_annotated.py +0 -0
  130. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/testing_conftest.py +0 -0
  131. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/timestamps.py +0 -0
  132. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/timestamps_annotated.py +0 -0
  133. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/transactions.py +0 -0
  134. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/traversal.py +0 -0
  135. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/traversal_annotated.py +0 -0
  136. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/connection.md +0 -0
  137. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/exceptions.md +0 -0
  138. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/fields.md +0 -0
  139. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/migrations.md +0 -0
  140. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/model.md +0 -0
  141. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/raw-sql.md +0 -0
  142. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/relationships.md +0 -0
  143. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/transactions.md +0 -0
  144. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/changelog.md +0 -0
  145. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/architecture.md +0 -0
  146. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/backends.md +0 -0
  147. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/identity-map.md +0 -0
  148. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/performance.md +0 -0
  149. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/type-safety.md +0 -0
  150. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/contributing.md +0 -0
  151. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/faq.md +0 -0
  152. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/getting-started/installation.md +0 -0
  153. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/getting-started/next-steps.md +0 -0
  154. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/getting-started/quickstart.md +0 -0
  155. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/connections.md +0 -0
  156. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/migrations.md +0 -0
  157. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/models-and-fields.md +0 -0
  158. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/mutations.md +0 -0
  159. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/relationships.md +0 -0
  160. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/transactions.md +0 -0
  161. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/multiple-databases.md +0 -0
  162. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/pagination.md +0 -0
  163. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/soft-deletes.md +0 -0
  164. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/testing.md +0 -0
  165. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/timestamps.md +0 -0
  166. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/upgrade-guide.md +0 -0
  167. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/index.md +0 -0
  168. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/stylesheets/extra.css +0 -0
  169. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  170. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  171. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  172. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  173. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  174. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  175. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
  176. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
  177. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-24-001-feat-ir-p8-119-wire-automigrate-plan.md +0 -0
  178. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-25-001-feat-ir-p8-120-parity-gate-plan.md +0 -0
  179. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-26-001-feat-ir-p8.5-140-shared-lowering-plan.md +0 -0
  180. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-26-002-feat-ir-p8.5-143-db-type-drop-plan.md +0 -0
  181. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-26-003-feat-ir-p8.5-144-reconcile-indexes-plan.md +0 -0
  182. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-28-001-feat-ir-p8.5-141-single-schema-ir-producer-plan.md +0 -0
  183. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-29-001-refactor-ir-p8.6-153-create-path-unification-plan.md +0 -0
  184. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-29-002-refactor-ir-p8.6-146-dialect-enum-unification-plan.md +0 -0
  185. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-30-001-refactor-ir-p8.6-158-check-renderer-plan.md +0 -0
  186. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-30-002-fix-ir-p8.6-155-deferred-annotations-plan.md +0 -0
  187. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-01-001-feat-ir-p8.6-162-typed-save-bind-plan.md +0 -0
  188. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-01-002-fix-ir-p8.6-154-datetime-tz-coarseness-plan.md +0 -0
  189. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-01-003-fix-ir-p8.6-165-blob-introspection-plan.md +0 -0
  190. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-001-fable-fixes-roadmap.md +0 -0
  191. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-002-feat-ff-a-172-typed-exceptions-plan.md +0 -0
  192. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-003-ff-c-native-decode-design.md +0 -0
  193. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-004-ff-c-c2-catalog-cache-design.md +0 -0
  194. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-005-ff-d-identity-routing-design.md +0 -0
  195. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-006-ff-d-identity-routing-plan.md +0 -0
  196. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-03-007-ff-e-registry-identity-design.md +0 -0
  197. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-03-008-ff-e-registry-identity-plan.md +0 -0
  198. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-009-ff-f-query-builder-1.0-design.md +0 -0
  199. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-010-ff-f-query-builder-1.0-plan.md +0 -0
  200. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-011-ff-g-hardening-design.md +0 -0
  201. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-012-ff-g-hardening-plan.md +0 -0
  202. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-013-ff-g2-operations-dedup-design.md +0 -0
  203. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-014-ff-g2-operations-dedup-plan.md +0 -0
  204. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-015-docs-upgrade-guide-consolidation-plan.md +0 -0
  205. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/ir-first-migration-guide.md +0 -0
  206. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/ir-first-release-checklist.md +0 -0
  207. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/rfc/ir-contracts-v1.md +0 -0
  208. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/README.md +0 -0
  209. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/architecture-patterns/ir-first-lowering-consolidation-audit.md +0 -0
  210. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
  211. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  212. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  213. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  214. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  215. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
  216. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  217. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  218. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  219. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  220. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
  221. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/derived-type-and-naming-decision-table.md +0 -0
  222. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  223. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  224. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/ir-invariants.md +0 -0
  225. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/persistence-state.md +0 -0
  226. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  227. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
  228. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
  229. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/justfile +0 -0
  230. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/scripts/demo_queries.py +0 -0
  231. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/scripts/demo_queries_pre_v012.py +0 -0
  232. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/backend.rs +0 -0
  233. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/codec.rs +0 -0
  234. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/codec_plan.rs +0 -0
  235. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/connection.rs +0 -0
  236. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/errors.rs +0 -0
  237. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/__init__.py +0 -0
  238. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_annotation_utils.py +0 -0
  239. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_bind_payload.py +0 -0
  240. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_core.pyi +0 -0
  241. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_deprecations.py +0 -0
  242. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_shadow_fk_types.py +0 -0
  243. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/base.py +0 -0
  244. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/columns.py +0 -0
  245. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/composite_indexes.py +0 -0
  246. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/composite_uniques.py +0 -0
  247. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/exceptions.py +0 -0
  248. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/fields.py +0 -0
  249. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/ir/__init__.py +0 -0
  250. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/ir/compiler.py +0 -0
  251. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/metaclass.py +0 -0
  252. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/migrations/__init__.py +0 -0
  253. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/migrations/alembic.py +0 -0
  254. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/models.py +0 -0
  255. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/py.typed +0 -0
  256. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/rows.py +0 -0
  257. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/raw.py +0 -0
  258. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/relations/__init__.py +0 -0
  259. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/relations/descriptors.py +0 -0
  260. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/session.py +0 -0
  261. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/state.py +0 -0
  262. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/hydration.rs +0 -0
  263. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/introspect.rs +0 -0
  264. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/lib.rs +0 -0
  265. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/migrate.rs +0 -0
  266. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/naming_ffi.rs +0 -0
  267. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/schema.rs +0 -0
  268. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/schema_bind.rs +0 -0
  269. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/state.rs +0 -0
  270. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/__init__.py +0 -0
  271. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/conftest.py +0 -0
  272. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/db_backends.py +0 -0
  273. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
  274. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
  275. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
  276. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/schema_raw_str_pk_autoincrement_v1.json +0 -0
  277. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/bad_includes.py +0 -0
  278. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/bad_predicates.py +0 -0
  279. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/good_includes.py +0 -0
  280. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/good_predicates.py +0 -0
  281. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_aggregation.py +0 -0
  282. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_autogenerate.py +0 -0
  283. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_bridge.py +0 -0
  284. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_db_type.py +0 -0
  285. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_nullability.py +0 -0
  286. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_type_mapping.py +0 -0
  287. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_annotation_utils.py +0 -0
  288. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_auto_migrate.py +0 -0
  289. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_bulk_install.py +0 -0
  290. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_bulk_update.py +0 -0
  291. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_catalog_cache.py +0 -0
  292. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_codec_plan.py +0 -0
  293. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_column_specs.py +0 -0
  294. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_composite_index.py +0 -0
  295. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_composite_unique.py +0 -0
  296. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_connection.py +0 -0
  297. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_connection_redaction.py +0 -0
  298. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_constraints.py +0 -0
  299. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_cross_emitter_parity.py +0 -0
  300. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_crud.py +0 -0
  301. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_backends.py +0 -0
  302. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_cross_emitter_parity.py +0 -0
  303. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_integration.py +0 -0
  304. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_typing.py +0 -0
  305. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_validation.py +0 -0
  306. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_deletion.py +0 -0
  307. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_deprecations.py +0 -0
  308. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_docs_examples.py +0 -0
  309. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_documentation_features.py +0 -0
  310. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_enum_cold_hydration.py +0 -0
  311. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_exception_mapping.py +0 -0
  312. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_exceptions.py +0 -0
  313. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_field_wrapper.py +0 -0
  314. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_fk_target_pk.py +0 -0
  315. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_generation_counter_dirty_tracking.py +0 -0
  316. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_helpers.py +0 -0
  317. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_hydration.py +0 -0
  318. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_hydration_equivalence.py +0 -0
  319. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_memory.py +0 -0
  320. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_refresh.py +0 -0
  321. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_scoped_invalidation.py +0 -0
  322. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_weakref.py +0 -0
  323. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_import_budget.py +0 -0
  324. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_metaclass_internals.py +0 -0
  325. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_metadata.py +0 -0
  326. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_migrate_plan.py +0 -0
  327. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_model_identity.py +0 -0
  328. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_models.py +0 -0
  329. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_mutation_pagination_guard.py +0 -0
  330. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_named_connections_integration.py +0 -0
  331. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_naming_single_source.py +0 -0
  332. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_one_to_one.py +0 -0
  333. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_operation_seam_sync.py +0 -0
  334. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_provisional_import.py +0 -0
  335. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_column_validation.py +0 -0
  336. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_immutability.py +0 -0
  337. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_joins.py +0 -0
  338. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_typing.py +0 -0
  339. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_raw_sql.py +0 -0
  340. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_refresh.py +0 -0
  341. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_registry_entrypoints.py +0 -0
  342. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_relation_specs.py +0 -0
  343. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_relationship_engine.py +0 -0
  344. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_relationship_resolution_errors.py +0 -0
  345. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_route_single_site.py +0 -0
  346. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_routing_errors.py +0 -0
  347. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_save_pk_edges.py +0 -0
  348. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_save_semantics.py +0 -0
  349. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema.py +0 -0
  350. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema_constraints.py +0 -0
  351. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema_db_type_metadata.py +0 -0
  352. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema_enum_annotations.py +0 -0
  353. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_session.py +0 -0
  354. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_shadow_fk_types.py +0 -0
  355. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  356. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_string_search.py +0 -0
  357. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_structural_types.py +0 -0
  358. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_temporal_types.py +0 -0
  359. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_transactions.py +0 -0
  360. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_typed_null_binds.py +0 -0
  361. {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_typed_save_bind.py +0 -0
@@ -1,6 +1,37 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.16.0 (2026-07-13)
5
+
6
+ ### Chores
7
+
8
+ - ADR-0009 aggregate projections + output-alias/traversed-projection/aggregate-projection glossary
9
+ ([`51b8c0c`](https://github.com/syn54x/ferro-orm/commit/51b8c0c5fef2d383561b0127028ad5e7eb5bbbc3))
10
+
11
+ ### Documentation
12
+
13
+ - Aggregation guide, projection reference, typing page
14
+ ([#296](https://github.com/syn54x/ferro-orm/pull/296),
15
+ [`44230c5`](https://github.com/syn54x/ferro-orm/commit/44230c55b53544788210f0664dc2a32634409839))
16
+
17
+ ### Features
18
+
19
+ - Global aggregates — count/sum/avg/min/max ([#294](https://github.com/syn54x/ferro-orm/pull/294),
20
+ [`1679e44`](https://github.com/syn54x/ferro-orm/commit/1679e44399b8ecc9f47a4f416dbf9aa821173b16))
21
+
22
+ - Grouped aggregates, order_by rules, verb guardrails
23
+ ([#295](https://github.com/syn54x/ferro-orm/pull/295),
24
+ [`2408ad7`](https://github.com/syn54x/ferro-orm/commit/2408ad7ae91cf6e7024e7297764191967596e869))
25
+
26
+ - Traversed projection + output aliases ([#293](https://github.com/syn54x/ferro-orm/pull/293),
27
+ [`1e73e2a`](https://github.com/syn54x/ferro-orm/commit/1e73e2a8f53a4d56d3caaa38a754166023e4c361))
28
+
29
+ ### Refactoring
30
+
31
+ - QueryIR v5 — the expr record-field shape ([#292](https://github.com/syn54x/ferro-orm/pull/292),
32
+ [`dc779d0`](https://github.com/syn54x/ferro-orm/commit/dc779d0e1184e6c87e19872302a045c3f36b8e5f))
33
+
34
+
4
35
  ## v0.15.0 (2026-07-11)
5
36
 
6
37
  ### Chores
@@ -80,6 +80,18 @@ _Avoid_: Eager-loaded field, select_related, prefetched attribute, joined attrib
80
80
  A query's declaration of what its result columns become: complete root instances (every query today), a projected record of named fields, or — in the future — a populated instance graph. Every query carries exactly one plan; the plan travels with the query rather than being inferred from its column list.
81
81
  _Avoid_: Select list, projection spec, hydration mode flag
82
82
 
83
+ **Aggregate projection**:
84
+ A projection containing at least one aggregate field. Each group collapses to exactly one projected record: every non-aggregate field is a group key, so grouping is derived from the projection and never declared separately. With no non-aggregate fields, the whole result collapses to a single record. Grouping collapses rows — bucketing complete instances by a key ("partitioning") is a different, client-side operation and is not grouping.
85
+ _Avoid_: Group-by query, summary query, rollup, partition
86
+
87
+ **Traversed projection**:
88
+ A projected record field whose source column lives across a forward-FK relation path (`select(lambda t: t.account.name)`). Projection traversal narrows exactly like predicate traversal (ADR-0006). Unaliased, the field takes the bare leaf column name; two selected fields sharing an output name is a build-time error, resolved with an output alias.
89
+ _Avoid_: Nested select, join column, related-field pull
90
+
91
+ **Output alias**:
92
+ A user-chosen name for one field of a projected record, given as the key in a dict-returning selector (`select(lambda t: {"account_name": t.account.name})`). Aliases name output fields only — never joins or tables; the relation path remains the sole join identity.
93
+ _Avoid_: Column alias, AS label, join alias
94
+
83
95
  **Provisional registration**:
84
96
  The per-model state installed when a class body finishes executing — enough for runtime codec and PK metadata, but relationships may still be pending and the modelset is not yet authoritative for DDL.
85
97
  _Avoid_: Import-time registration, partial registry
@@ -327,7 +327,7 @@ dependencies = [
327
327
 
328
328
  [[package]]
329
329
  name = "ferro"
330
- version = "0.15.0"
330
+ version = "0.16.0"
331
331
  dependencies = [
332
332
  "chrono",
333
333
  "dashmap",
@@ -1905,9 +1905,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1905
1905
 
1906
1906
  [[package]]
1907
1907
  name = "uuid"
1908
- version = "1.23.4"
1908
+ version = "1.23.5"
1909
1909
  source = "registry+https://github.com/rust-lang/crates.io-index"
1910
- checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
1910
+ checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
1911
1911
  dependencies = [
1912
1912
  "getrandom 0.4.3",
1913
1913
  "js-sys",
@@ -2329,6 +2329,6 @@ dependencies = [
2329
2329
 
2330
2330
  [[package]]
2331
2331
  name = "zmij"
2332
- version = "1.0.21"
2332
+ version = "1.0.22"
2333
2333
  source = "registry+https://github.com/rust-lang/crates.io-index"
2334
- checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2334
+ checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
@@ -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.15.0"
6
+ version = "0.16.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.15.0
3
+ Version: 0.16.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,13 @@ 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: 4` (unconditional, no earlier version emitted anywhere —
141
- /// #269, #278, #285). `joins` is required and always present (`[]` when the
142
- /// query traverses no relation); every leaf and `order_by` entry carries a
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
143
  /// `path` (required, `[]` = root model); `materialization` is required — the
144
144
  /// plan travels with the query as data (ADR-0007), never inferred from a
145
- /// column list. v4 gives the `instances` kind its field shape: `paths` of
146
- /// hop facts (#285).
145
+ /// column list. v5 gives `record` fields expression sources: a field carries
146
+ /// either a `column` + `path` or an `expr` (#292, ADR-0009).
147
147
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148
148
  pub struct QueryIrPayload {
149
149
  /// Model class name the query targets.
@@ -200,23 +200,103 @@ pub enum Materialization {
200
200
 
201
201
  /// One projected field in a [`Materialization::Record`] plan.
202
202
  ///
203
- /// `name` is declared separately from the source `column`, the field carries a
204
- /// relation `path`, and may later carry an `expr` instead of a column — so
205
- /// output aliases, traversed projection, and aggregations (#282) extend this
206
- /// shape additively without reshaping the v3 contract.
203
+ /// `name` is declared separately from the source, so output aliases are free
204
+ /// (`name` is the alias). A field reads from exactly one source (v5, #292,
205
+ /// ADR-0009): a `column` + relation `path` (plain and traversed projection),
206
+ /// or an `expr` (aggregations) — never both, never neither, enforced at
207
+ /// deserialization via [`RecordFieldWire`]. GROUP BY never travels on the
208
+ /// wire: the renderer derives group keys from the plan — every non-`expr`
209
+ /// field is a key (ADR-0009).
207
210
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211
+ #[serde(try_from = "RecordFieldWire")]
208
212
  pub struct RecordField {
209
213
  /// Output field name on the projected record.
210
214
  pub name: String,
211
- /// Source column the value reads from (`name == column` until output
212
- /// aliases land with #282).
213
- pub column: String,
215
+ /// Source column the value reads from. `Some` iff `expr` is `None`.
216
+ #[serde(skip_serializing_if = "Option::is_none")]
217
+ pub column: Option<String>,
214
218
  /// Relation path from the root model to `column`'s table (`[]` = root
215
- /// model; non-empty paths are traversed projection, #282).
219
+ /// model; non-empty paths are traversed projection, #293). Belongs to the
220
+ /// `column` arm: an `expr` field carries its traversal on the expression
221
+ /// itself, so its outer `path` is always `[]`.
216
222
  pub path: Vec<String>,
217
- /// Reserved: expression source instead of a column (aggregations, #282).
218
- #[serde(default, skip_serializing_if = "Option::is_none")]
219
- pub expr: Option<Value>,
223
+ /// Expression source instead of a column (aggregations, #294/#295).
224
+ #[serde(skip_serializing_if = "Option::is_none")]
225
+ pub expr: Option<RecordExpr>,
226
+ }
227
+
228
+ /// The expression source of an aggregate record field (v5, #292, ADR-0009).
229
+ ///
230
+ /// Self-contained on the wire: `fn` travels as data (the closed aggregate
231
+ /// set), and `path` carries the source column's traversal as ordered hop
232
+ /// facts — the same hop shape as the `joins` section — rather than keying
233
+ /// into it.
234
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
235
+ pub struct RecordExpr {
236
+ /// Aggregate function applied to `column`.
237
+ pub r#fn: AggregateFn,
238
+ /// Source column the aggregate reads from.
239
+ pub column: String,
240
+ /// Relation hops from the root model to `column`'s table (`[]` = root
241
+ /// model), in the `joins`-section hop-fact shape.
242
+ pub path: Vec<QueryJoinHop>,
243
+ }
244
+
245
+ /// The closed set of aggregate functions a [`RecordExpr`] may apply
246
+ /// (ADR-0009). Carried as data on the wire; an unknown token fails the strict
247
+ /// parse naming the supported set.
248
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
249
+ #[serde(rename_all = "lowercase")]
250
+ pub enum AggregateFn {
251
+ Count,
252
+ Sum,
253
+ Avg,
254
+ Min,
255
+ Max,
256
+ }
257
+
258
+ /// Field-wise wire shape backing [`RecordField`] deserialization, so the
259
+ /// exactly-one-source invariant is a parse-time rejection, not a downstream
260
+ /// walker check.
261
+ #[derive(Debug, Deserialize)]
262
+ struct RecordFieldWire {
263
+ name: String,
264
+ #[serde(default)]
265
+ column: Option<String>,
266
+ path: Vec<String>,
267
+ #[serde(default)]
268
+ expr: Option<RecordExpr>,
269
+ }
270
+
271
+ impl TryFrom<RecordFieldWire> for RecordField {
272
+ type Error = String;
273
+
274
+ fn try_from(wire: RecordFieldWire) -> Result<Self, Self::Error> {
275
+ match (&wire.column, &wire.expr) {
276
+ (Some(_), Some(_)) => Err(format!(
277
+ "record field {:?} carries both `column` and `expr`; \
278
+ a field reads from exactly one source",
279
+ wire.name
280
+ )),
281
+ (None, None) => Err(format!(
282
+ "record field {:?} carries neither `column` nor `expr`; \
283
+ a field reads from exactly one source",
284
+ wire.name
285
+ )),
286
+ (None, Some(_)) if !wire.path.is_empty() => Err(format!(
287
+ "record field {:?} carries an `expr` and a non-empty field \
288
+ `path` {:?}; an expression field's traversal travels on \
289
+ `expr.path` (hop facts), so its field `path` must be empty",
290
+ wire.name, wire.path
291
+ )),
292
+ _ => Ok(RecordField {
293
+ name: wire.name,
294
+ column: wire.column,
295
+ path: wire.path,
296
+ expr: wire.expr,
297
+ }),
298
+ }
299
+ }
220
300
  }
221
301
 
222
302
  /// One relation JOIN collected from query-time traversal (#270 lands rendering).
@@ -358,7 +438,7 @@ mod tests {
358
438
  #[test]
359
439
  fn query_fixture_roundtrip() {
360
440
  let fixture =
361
- include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v4.json");
441
+ include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v5.json");
362
442
  let parsed: serde_json::Value =
363
443
  serde_json::from_str(fixture).expect("query fixture must parse");
364
444
  let ir = parsed
@@ -381,7 +461,7 @@ mod tests {
381
461
  // Multi-hop `joins` section + path-carrying leaves must survive a
382
462
  // deserialize/serialize round-trip without drift (#270 wire stability).
383
463
  let fixture = include_str!(
384
- "../../../tests/fixtures/ir_vectors/query_transaction_traversal_v4.json"
464
+ "../../../tests/fixtures/ir_vectors/query_transaction_traversal_v5.json"
385
465
  );
386
466
  let parsed: serde_json::Value =
387
467
  serde_json::from_str(fixture).expect("query traversal fixture must parse");
@@ -407,7 +487,7 @@ mod tests {
407
487
  // deserialize/serialize round-trip without drift, and the join_type
408
488
  // tokens must reach Rust exactly as written on the wire.
409
489
  let fixture =
410
- include_str!("../../../tests/fixtures/ir_vectors/query_transaction_left_join_v4.json");
490
+ include_str!("../../../tests/fixtures/ir_vectors/query_transaction_left_join_v5.json");
411
491
  let parsed: serde_json::Value =
412
492
  serde_json::from_str(fixture).expect("query left_join fixture must parse");
413
493
  let ir = parsed
@@ -433,7 +513,7 @@ mod tests {
433
513
  // payload — predicate, order, limit, and a two-field record plan —
434
514
  // survives a deserialize/serialize round-trip without drift.
435
515
  let fixture =
436
- include_str!("../../../tests/fixtures/ir_vectors/query_transaction_record_v4.json");
516
+ include_str!("../../../tests/fixtures/ir_vectors/query_transaction_record_v5.json");
437
517
  let parsed: serde_json::Value =
438
518
  serde_json::from_str(fixture).expect("query record fixture must parse");
439
519
  let ir = parsed
@@ -474,13 +554,271 @@ mod tests {
474
554
  };
475
555
  assert_eq!(fields.len(), 2);
476
556
  assert_eq!(fields[0].name, "id");
477
- assert_eq!(fields[0].column, "id");
557
+ assert_eq!(fields[0].column.as_deref(), Some("id"));
478
558
  assert!(fields[0].path.is_empty());
479
- assert!(fields[0].expr.is_none(), "expr is reserved and absent");
559
+ assert!(fields[0].expr.is_none(), "a column field carries no expr");
480
560
  let encoded = serde_json::to_value(&plan).expect("record kind must serialize");
481
561
  assert_eq!(encoded, wire, "record round-trip must not drift");
482
562
  }
483
563
 
564
+ #[test]
565
+ fn traversed_record_field_roundtrips() {
566
+ // v5 (#292): a plain traversed record field — non-empty relation
567
+ // `path`, a `column`, no `expr` — is the shape the tracer-bullet
568
+ // slice (#293) consumes. The path stays relation names (it keys into
569
+ // the `joins` section, like `order_by.path`), not hop facts.
570
+ let wire = serde_json::json!({
571
+ "kind": "record",
572
+ "fields": [
573
+ {"name": "account_name", "column": "name", "path": ["account"]},
574
+ {"name": "owner_email", "column": "email",
575
+ "path": ["account", "owner"]}
576
+ ]
577
+ });
578
+ let plan: Materialization =
579
+ serde_json::from_value(wire.clone()).expect("traversed fields must deserialize");
580
+ let Materialization::Record { fields } = &plan else {
581
+ panic!("expected Record, got {plan:?}");
582
+ };
583
+ assert_eq!(fields[0].path, vec!["account".to_string()]);
584
+ assert_eq!(fields[1].path.len(), 2);
585
+ let encoded = serde_json::to_value(&plan).expect("traversed fields must serialize");
586
+ assert_eq!(encoded, wire, "traversed record round-trip must not drift");
587
+ }
588
+
589
+ #[test]
590
+ fn record_expr_fields_roundtrip() {
591
+ // v5 (#292): expression fields — root and traversed sources. `fn`
592
+ // travels as data; `expr.path` carries hop facts (the `joins`-section
593
+ // shape), self-contained rather than keying into the joins list.
594
+ let wire = serde_json::json!({
595
+ "kind": "record",
596
+ "fields": [
597
+ {"name": "acct", "column": "account_id", "path": []},
598
+ {"name": "total", "path": [],
599
+ "expr": {"fn": "sum", "column": "amount", "path": []}},
600
+ {"name": "avg_balance", "path": [],
601
+ "expr": {"fn": "avg", "column": "balance", "path": [
602
+ {"relation": "account", "from_column": "account_id",
603
+ "to_table": "account", "to_column": "id"}
604
+ ]}}
605
+ ]
606
+ });
607
+ let plan: Materialization =
608
+ serde_json::from_value(wire.clone()).expect("expr fields must deserialize");
609
+ let Materialization::Record { fields } = &plan else {
610
+ panic!("expected Record, got {plan:?}");
611
+ };
612
+ assert_eq!(fields.len(), 3);
613
+ // The group-key field is a plain column source.
614
+ assert_eq!(fields[0].column.as_deref(), Some("account_id"));
615
+ assert!(fields[0].expr.is_none());
616
+ // The root aggregate: fn as data, no column, empty hop path.
617
+ let total = fields[1].expr.as_ref().expect("total must carry an expr");
618
+ assert!(fields[1].column.is_none());
619
+ assert_eq!(total.r#fn, AggregateFn::Sum);
620
+ assert_eq!(total.column, "amount");
621
+ assert!(total.path.is_empty());
622
+ // The traversed aggregate: hop facts on the expression itself.
623
+ let avg = fields[2].expr.as_ref().expect("avg must carry an expr");
624
+ assert_eq!(avg.r#fn, AggregateFn::Avg);
625
+ assert_eq!(avg.path.len(), 1);
626
+ assert_eq!(avg.path[0].relation, "account");
627
+ assert_eq!(avg.path[0].from_column, "account_id");
628
+ let encoded = serde_json::to_value(&plan).expect("expr fields must serialize");
629
+ assert_eq!(encoded, wire, "expr record round-trip must not drift");
630
+ }
631
+
632
+ #[test]
633
+ fn record_field_with_both_column_and_expr_is_rejected() {
634
+ // Exactly-one-of is a deserialization invariant (#292): both sources
635
+ // present fails the strict parse naming the field.
636
+ let err = serde_json::from_value::<Materialization>(serde_json::json!({
637
+ "kind": "record",
638
+ "fields": [{
639
+ "name": "total", "column": "amount", "path": [],
640
+ "expr": {"fn": "sum", "column": "amount", "path": []}
641
+ }]
642
+ }))
643
+ .expect_err("both column and expr must fail to deserialize");
644
+ let msg = err.to_string();
645
+ assert!(
646
+ msg.contains("total") && msg.contains("both"),
647
+ "must name the field and the violation: {msg}"
648
+ );
649
+ }
650
+
651
+ #[test]
652
+ fn record_field_with_neither_column_nor_expr_is_rejected() {
653
+ let err = serde_json::from_value::<Materialization>(serde_json::json!({
654
+ "kind": "record",
655
+ "fields": [{"name": "total", "path": []}]
656
+ }))
657
+ .expect_err("neither column nor expr must fail to deserialize");
658
+ let msg = err.to_string();
659
+ assert!(
660
+ msg.contains("total") && msg.contains("neither"),
661
+ "must name the field and the violation: {msg}"
662
+ );
663
+ }
664
+
665
+ #[test]
666
+ fn record_expr_with_unknown_fn_is_rejected() {
667
+ // `fn` is the closed aggregate set (ADR-0009); an unknown token fails
668
+ // the strict parse naming the bad token and the supported set.
669
+ let err = serde_json::from_value::<Materialization>(serde_json::json!({
670
+ "kind": "record",
671
+ "fields": [{
672
+ "name": "mid", "path": [],
673
+ "expr": {"fn": "median", "column": "amount", "path": []}
674
+ }]
675
+ }))
676
+ .expect_err("an unknown aggregate fn must fail to deserialize");
677
+ let msg = err.to_string();
678
+ assert!(msg.contains("median"), "must name the bad fn: {msg}");
679
+ for supported in ["count", "sum", "avg", "min", "max"] {
680
+ assert!(
681
+ msg.contains(supported),
682
+ "must name supported fn {supported:?}: {msg}"
683
+ );
684
+ }
685
+ }
686
+
687
+ #[test]
688
+ fn record_expr_field_with_outer_path_is_rejected() {
689
+ // An expression field's traversal travels on `expr.path`; a non-empty
690
+ // field-level `path` next to an `expr` is two contradictory traversal
691
+ // claims and fails the strict parse.
692
+ let err = serde_json::from_value::<Materialization>(serde_json::json!({
693
+ "kind": "record",
694
+ "fields": [{
695
+ "name": "avg_balance", "path": ["account"],
696
+ "expr": {"fn": "avg", "column": "balance", "path": []}
697
+ }]
698
+ }))
699
+ .expect_err("an expr field with a field-level path must fail to deserialize");
700
+ let msg = err.to_string();
701
+ assert!(
702
+ msg.contains("avg_balance") && msg.contains("expr.path"),
703
+ "must name the field and point at expr.path: {msg}"
704
+ );
705
+ }
706
+
707
+ #[test]
708
+ fn query_global_aggregate_fixture_roundtrip() {
709
+ // The global-aggregate golden vector (#294): an aggregate-only record
710
+ // plan — count, root sum, and a traversed avg carrying hop facts on
711
+ // the expression, plus a `joins` entry sharing the avg's path (join
712
+ // identity) — survives a deserialize/serialize round-trip without
713
+ // drift. No group keys: the whole result collapses to one record.
714
+ let fixture = include_str!(
715
+ "../../../tests/fixtures/ir_vectors/query_transaction_global_aggregate_v5.json"
716
+ );
717
+ let parsed: serde_json::Value =
718
+ serde_json::from_str(fixture).expect("global aggregate fixture must parse");
719
+ let ir = parsed
720
+ .get("ir")
721
+ .cloned()
722
+ .expect("fixture must contain ir envelope");
723
+ let envelope: IrEnvelope<QueryIrPayload> = serde_json::from_value(ir.clone())
724
+ .expect("global aggregate IR must deserialize");
725
+ let Materialization::Record { fields } = &envelope.payload.materialization else {
726
+ panic!(
727
+ "expected a record plan, got {:?}",
728
+ envelope.payload.materialization
729
+ );
730
+ };
731
+ assert_eq!(fields.len(), 3);
732
+ assert!(
733
+ fields.iter().all(|f| f.expr.is_some()),
734
+ "a global aggregate plan is aggregate-only"
735
+ );
736
+ assert_eq!(
737
+ fields[0].expr.as_ref().map(|e| e.r#fn),
738
+ Some(AggregateFn::Count)
739
+ );
740
+ let encoded =
741
+ serde_json::to_value(&envelope).expect("global aggregate IR must serialize");
742
+ assert_eq!(encoded, ir, "global aggregate round-trip must not drift");
743
+ }
744
+
745
+ #[test]
746
+ fn query_traversed_record_fixture_roundtrip() {
747
+ // The traversed-projection golden vector (#293): an aliased root
748
+ // field, a 1-hop field under a LEFT-marked path, and a 2-hop field
749
+ // sharing that path's prefix — the full dict-selector wire shape,
750
+ // joins section included — survives a deserialize/serialize
751
+ // round-trip without drift.
752
+ let fixture = include_str!(
753
+ "../../../tests/fixtures/ir_vectors/query_transaction_traversed_record_v5.json"
754
+ );
755
+ let parsed: serde_json::Value =
756
+ serde_json::from_str(fixture).expect("query traversed record fixture must parse");
757
+ let ir = parsed
758
+ .get("ir")
759
+ .cloned()
760
+ .expect("fixture must contain ir envelope");
761
+ let envelope: IrEnvelope<QueryIrPayload> = serde_json::from_value(ir.clone())
762
+ .expect("query traversed record IR must deserialize");
763
+ let Materialization::Record { fields } = &envelope.payload.materialization else {
764
+ panic!(
765
+ "expected a record plan, got {:?}",
766
+ envelope.payload.materialization
767
+ );
768
+ };
769
+ assert_eq!(fields.len(), 3);
770
+ // The root field is aliased: name and column differ.
771
+ assert_eq!(fields[0].name, "txn_id");
772
+ assert_eq!(fields[0].column.as_deref(), Some("id"));
773
+ // Traversed fields carry relation-name paths keying into `joins`.
774
+ assert_eq!(fields[1].path, vec!["account".to_string()]);
775
+ assert_eq!(fields[2].path.len(), 2);
776
+ assert_eq!(envelope.payload.joins[0].join_type, "left");
777
+ let encoded =
778
+ serde_json::to_value(&envelope).expect("query traversed record IR must serialize");
779
+ assert_eq!(encoded, ir, "query traversed record round-trip must not drift");
780
+ }
781
+
782
+ #[test]
783
+ fn query_aggregate_fixture_roundtrip() {
784
+ // The aggregate-projection golden vector (#292): a grouped query's
785
+ // full payload — a traversed group key sharing its path with the
786
+ // `joins` section, a root aggregate, a traversed aggregate carrying
787
+ // hop facts on the expression, and an output-name ORDER BY — survives
788
+ // a deserialize/serialize round-trip without drift. GROUP BY does not
789
+ // travel: the renderer derives it from the non-expr fields (ADR-0009).
790
+ let fixture = include_str!(
791
+ "../../../tests/fixtures/ir_vectors/query_transaction_aggregate_v5.json"
792
+ );
793
+ let parsed: serde_json::Value =
794
+ serde_json::from_str(fixture).expect("query aggregate fixture must parse");
795
+ let ir = parsed
796
+ .get("ir")
797
+ .cloned()
798
+ .expect("fixture must contain ir envelope");
799
+ let envelope: IrEnvelope<QueryIrPayload> =
800
+ serde_json::from_value(ir.clone()).expect("query aggregate IR must deserialize");
801
+ let Materialization::Record { fields } = &envelope.payload.materialization else {
802
+ panic!(
803
+ "expected a record plan, got {:?}",
804
+ envelope.payload.materialization
805
+ );
806
+ };
807
+ assert_eq!(fields.len(), 3);
808
+ assert_eq!(fields[0].path, vec!["account".to_string()]);
809
+ assert_eq!(
810
+ fields[1].expr.as_ref().map(|e| e.r#fn),
811
+ Some(AggregateFn::Sum)
812
+ );
813
+ assert_eq!(
814
+ fields[2].expr.as_ref().map(|e| e.path.len()),
815
+ Some(1),
816
+ "the traversed aggregate carries its hop facts"
817
+ );
818
+ let encoded = serde_json::to_value(&envelope).expect("query aggregate IR must serialize");
819
+ assert_eq!(encoded, ir, "query aggregate round-trip must not drift");
820
+ }
821
+
484
822
  #[test]
485
823
  fn instances_materialization_roundtrips() {
486
824
  // v4 (#285): the `instances` kind carries `paths` — ordered hop-fact
@@ -518,7 +856,7 @@ mod tests {
518
856
  // payload — root predicate, empty `joins`, and a one-path instances
519
857
  // plan — survives a deserialize/serialize round-trip without drift.
520
858
  let fixture =
521
- include_str!("../../../tests/fixtures/ir_vectors/query_transaction_include_v4.json");
859
+ include_str!("../../../tests/fixtures/ir_vectors/query_transaction_include_v5.json");
522
860
  let parsed: serde_json::Value =
523
861
  serde_json::from_str(fixture).expect("query include fixture must parse");
524
862
  let ir = parsed
@@ -0,0 +1,70 @@
1
+ # Aggregate projections: grouping derived from the projection, pinned decode contract, flat record+relation results
2
+
3
+ Aggregation rides the `record` materialization plan (ADR-0007): aggregate
4
+ fields (`t.amount.sum()` — methods on scalar field proxies; the closed set
5
+ `count/sum/avg/min/max`) land in projected records, and a projection
6
+ containing any aggregate is an **aggregate projection** (`CONTEXT.md`) in
7
+ which every non-aggregate field is a group key — GROUP BY is **derived from
8
+ the projection, never declared**; there is no `group_by()` chainer. Aggregate
9
+ result types are a **pinned cross-backend contract** derived from the source
10
+ column's Python type (`count → int`; `min`/`max` → source type and codec;
11
+ `sum` → source numeric type; `avg` → float for int/float, Decimal for
12
+ Decimal); source families with no portable cross-backend meaning (enum, uuid,
13
+ json, bool) are rejected at build time. Record results that reach across a
14
+ relation are **flat** — traversed projection with output aliases — and
15
+ `include()` × `select()` is rejected permanently (flattened-as-final),
16
+ closing the question ADR-0008 parked with #282.
17
+
18
+ Decision by owner (2026-07-12), grilling #282 on the partial-materialization
19
+ substrate (#277/#279).
20
+
21
+ Rejected alternatives:
22
+
23
+ - **An explicit `group_by()` chainer**: SQL's own rule already forces every
24
+ bare selected column to be a group key, so a chainer is pure redundancy
25
+ plus a new error class ("bare column not in group_by") that Postgres
26
+ rejects at runtime and SQLite answers with an *arbitrary row's value*.
27
+ Derivation makes the invalid query unwritable and keeps the dict literal
28
+ the whole story: the record shape determines the grouping. The only
29
+ expressiveness lost — grouping by an unselected column — can arrive later
30
+ additively; the reverse (retiring a shipped chainer) could not.
31
+ - **Backend-native result types** (driver passthrough): Postgres `SUM(int8)`
32
+ and `AVG(int)` are `numeric`; SQLite `sum(int)` is int and `avg` is float —
33
+ the same query would return different Python types per backend.
34
+ - **Aggregating any column family, letting the DB decide**: `min`/`max` over
35
+ uuid does not exist on Postgres, and over a native enum silently diverges
36
+ (definition order vs. SQLite's lexical text order). Where no portable
37
+ meaning exists, build time is the only honest place to fail.
38
+ - **Nested record+relation results** (`Row(amount=…, account=<Account>)`): a
39
+ third result shape — part record, part instance — needing its own decode,
40
+ typing, and identity story, while every concrete need is served flatter and
41
+ clearer by a traversed, aliased field. Records carry values; instances
42
+ carry rows.
43
+ - **Coalescing empty aggregates to zero**: "sum of no rows" and "sum of rows
44
+ totaling zero" are different facts, and zero is the wrong identity for
45
+ `min`/`max` anyway. SQL's NULL passes through as `None` uniformly
46
+ (`count → 0`), with no hidden COALESCE.
47
+
48
+ ## Consequences
49
+
50
+ - The canonical grouped query is one lambda —
51
+ `select(lambda t: {"acct": t.account_id, "total": t.amount.sum()})` renders
52
+ `GROUP BY account_id`. Aggregate-only projections collapse to one record
53
+ (read with `first()`); a grouped query over zero rows returns zero records.
54
+ - GROUP BY never travels on the wire: the renderer derives the keys from the
55
+ v5 `record` plan (every non-`expr` field), pinned by golden vectors —
56
+ renderer work, like joins rendering from paths.
57
+ - Aggregate record fields are `T | None` (`count` excepted) — empty-input
58
+ NULL passes through.
59
+ - `order_by` strings resolve output field names before root columns on a
60
+ projected query (SQL's own ORDER BY scoping); on an aggregate projection
61
+ every sort key must be a group key or an aggregate — build-time error, the
62
+ SQLite arbitrary-row trap made unwritable. `limit()`/`offset()` act on
63
+ groups.
64
+ - `count()`/`exists()` raise on an aggregate projection with guidance (rows:
65
+ unprojected query; groups: `len(await q.all())`) — #279's "unaffected by
66
+ projection" contract is the *plain*-projection rule.
67
+ - `where()` never accepts an aggregate predicate; post-aggregation filtering
68
+ is `having()` (#291).
69
+ - A left-joined traversed group key yields a `None`-keyed group — "has no
70
+ relation" is a visible bucket, not a dropped row.