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.
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/CHANGELOG.md +31 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/CONTEXT.md +12 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/Cargo.lock +5 -5
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/Cargo.toml +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/PKG-INFO +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-schema-ir/src/lib.rs +361 -23
- ferro_orm-0.16.0/docs/adr/0009-aggregate-projections-derived-grouping.md +70 -0
- ferro_orm-0.16.0/docs/examples/aggregations.py +259 -0
- ferro_orm-0.16.0/docs/examples/aggregations_annotated.py +52 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/partial_selects.py +75 -2
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/partial_selects_annotated.py +10 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/populated_relations.py +1 -1
- ferro_orm-0.16.0/docs/pages/api/queries.md +46 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/query-typing.md +27 -1
- ferro_orm-0.16.0/docs/pages/guide/aggregations.md +162 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/queries.md +44 -8
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/raw-sql.md +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/migrate-from-sqlalchemy.md +4 -4
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/roadmap.md +2 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/why-ferro.md +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/pyproject.toml +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/__init__.py +2 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/builder.py +518 -124
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/nodes.py +191 -8
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/operations.rs +941 -132
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/query.rs +14 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/README.md +5 -4
- ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_aggregate_v5.json +82 -0
- ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v5.json +80 -0
- 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
- 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
- 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
- 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
- ferro_orm-0.16.0/tests/fixtures/ir_vectors/query_transaction_traversed_record_v5.json +84 -0
- 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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/bad_projections.py +10 -0
- ferro_orm-0.16.0/tests/static_fixtures/good_projections.py +109 -0
- ferro_orm-0.16.0/tests/test_global_aggregates.py +374 -0
- ferro_orm-0.16.0/tests/test_grouped_aggregates.py +406 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_include.py +10 -2
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_ir_vectors_contract.py +41 -9
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_partial_selects.py +4 -6
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_builder.py +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_static_contracts.py +10 -0
- ferro_orm-0.16.0/tests/test_traversed_projection.py +427 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/uv.lock +1 -1
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/zensical.toml +1 -0
- ferro_orm-0.15.0/docs/pages/api/queries.md +0 -37
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.gitignore +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/.python-version +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/AGENTS.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/LICENSE +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/README.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/README.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/__init__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/__main__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/backends.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/baselines/postgres.json +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/baselines/sqlite.json +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/compare.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/harness.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/model.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/benchmarks/run.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-ddl-lowering/Cargo.toml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-ddl-lowering/src/lib.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/Cargo.toml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/src/emit.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/src/lib.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-migrate/src/tests.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/crates/ferro-schema-ir/Cargo.toml +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0001-resolved-epoch-registration.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0002-unified-column-fact-derivation.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0003-path-blind-db-type-validation.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0004-jsonb-postgres-only-canonical.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0005-jsonb-default-on-postgres.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0006-relation-traversal-inner-joins.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0007-materialization-plan-complete-instances.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/adr/0008-populated-relations-include.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/agents/domain.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/agents/issue-tracker.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/agents/triage-labels.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/architecture-review-2026-07-08.html +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-24-ir-p8-119-wire-automigrate-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-06-25-ir-p8-120-parity-gate-requirements.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-02-ff-c1-codec-plan-design.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/brainstorms/2026-07-06-upgrade-guide-consolidation-requirements.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/multiple_databases.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/mutations.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/pagination.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/populated_relations_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/predicates.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/predicates_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/quickstart.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/quickstart_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/raw_sql.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/relationships.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/relationships_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/soft_deletes.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/soft_deletes_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/testing_conftest.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/timestamps.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/timestamps_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/transactions.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/traversal.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/examples/traversal_annotated.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/connection.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/exceptions.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/fields.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/migrations.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/model.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/raw-sql.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/relationships.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/api/transactions.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/changelog.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/architecture.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/backends.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/identity-map.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/performance.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/concepts/type-safety.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/contributing.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/faq.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/getting-started/installation.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/getting-started/next-steps.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/getting-started/quickstart.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/connections.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/migrations.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/models-and-fields.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/mutations.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/relationships.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/guide/transactions.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/multiple-databases.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/pagination.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/soft-deletes.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/testing.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/timestamps.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/howto/upgrade-guide.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/index.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/pages/stylesheets/extra.css +0 -0
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {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
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-001-fable-fixes-roadmap.md +0 -0
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-003-ff-c-native-decode-design.md +0 -0
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-005-ff-d-identity-routing-design.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-02-006-ff-d-identity-routing-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-03-007-ff-e-registry-identity-design.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-03-008-ff-e-registry-identity-plan.md +0 -0
- {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
- {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
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-011-ff-g-hardening-design.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-012-ff-g-hardening-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-013-ff-g2-operations-dedup-design.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-014-ff-g2-operations-dedup-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/2026-07-06-015-docs-upgrade-guide-consolidation-plan.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/ir-first-migration-guide.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/plans/ir-first-release-checklist.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/rfc/ir-contracts-v1.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/README.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/architecture-patterns/ir-first-lowering-consolidation-audit.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/derived-type-and-naming-decision-table.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/ir-invariants.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/persistence-state.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/justfile +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/scripts/demo_queries_pre_v012.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/backend.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/codec.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/codec_plan.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/connection.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/errors.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_bind_payload.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_deprecations.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/base.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/columns.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/fields.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/ir/__init__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/ir/compiler.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/models.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/query/rows.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/raw.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/session.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/ferro/state.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/hydration.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/introspect.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/lib.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/migrate.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/naming_ffi.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/schema.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/schema_bind.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/src/state.rs +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/__init__.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/conftest.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/fixtures/ir_vectors/schema_raw_str_pk_autoincrement_v1.json +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/bad_includes.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/bad_predicates.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/good_includes.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/static_fixtures/good_predicates.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_annotation_utils.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_bulk_install.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_catalog_cache.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_codec_plan.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_column_specs.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_connection.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_crud.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_deletion.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_deprecations.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_enum_cold_hydration.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_exception_mapping.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_exceptions.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_fk_target_pk.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_generation_counter_dirty_tracking.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_hydration_equivalence.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_memory.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_refresh.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_scoped_invalidation.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_identity_weakref.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_import_budget.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_migrate_plan.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_model_identity.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_models.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_mutation_pagination_guard.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_naming_single_source.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_operation_seam_sync.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_provisional_import.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_column_validation.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_immutability.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_joins.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_registry_entrypoints.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_relation_specs.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_relationship_resolution_errors.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_route_single_site.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_routing_errors.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_save_pk_edges.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_save_semantics.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_session.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_transactions.py +0 -0
- {ferro_orm-0.15.0 → ferro_orm-0.16.0}/tests/test_typed_null_binds.py +0 -0
- {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.
|
|
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.
|
|
1908
|
+
version = "1.23.5"
|
|
1909
1909
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1910
|
-
checksum = "
|
|
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.
|
|
2332
|
+
version = "1.0.22"
|
|
2333
2333
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2334
|
-
checksum = "
|
|
2334
|
+
checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
|
|
@@ -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:
|
|
141
|
-
/// #269, #278, #285). `joins` is required and always present (`[]` when
|
|
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.
|
|
146
|
-
///
|
|
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
|
|
204
|
-
///
|
|
205
|
-
///
|
|
206
|
-
///
|
|
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
|
|
212
|
-
|
|
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, #
|
|
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
|
-
///
|
|
218
|
-
#[serde(
|
|
219
|
-
pub expr: Option<
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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(), "
|
|
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/
|
|
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.
|