ferro-orm 0.12.0__tar.gz → 0.12.2__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.12.0 → ferro_orm-0.12.2}/AGENTS.md +20 -1
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/CHANGELOG.md +17 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/Cargo.lock +1 -1
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/Cargo.toml +1 -1
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/PKG-INFO +1 -1
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/connection.md +2 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/connections.md +31 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/migrating-to-v0-12-0.md +4 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/pyproject.toml +1 -1
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_core.pyi +1 -1
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/models.py +18 -15
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/relations/descriptors.py +3 -4
- ferro_orm-0.12.2/src/ferro/session.py +122 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/state.py +20 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/operations.rs +3 -2
- ferro_orm-0.12.2/tests/test_framework_predicates.py +72 -0
- ferro_orm-0.12.2/tests/test_session.py +387 -0
- ferro_orm-0.12.0/src/ferro/session.py +0 -43
- ferro_orm-0.12.0/tests/test_session.py +0 -104
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.gitignore +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.python-version +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/LICENSE +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/README.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-migrate/Cargo.toml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-migrate/src/lib.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-schema-ir/Cargo.toml +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-schema-ir/src/lib.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/multiple_databases.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/mutations.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/pagination.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/predicates.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/predicates_annotated.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/quickstart.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/quickstart_annotated.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/raw_sql.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/relationships.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/relationships_annotated.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/soft_deletes.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/soft_deletes_annotated.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/testing_conftest.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/timestamps.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/timestamps_annotated.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/transactions.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/exceptions.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/fields.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/migrations.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/model.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/queries.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/raw-sql.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/relationships.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/transactions.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/changelog.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/architecture.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/backends.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/identity-map.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/performance.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/query-typing.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/type-safety.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/contributing.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/faq.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/getting-started/installation.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/getting-started/next-steps.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/getting-started/quickstart.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/migrations.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/models-and-fields.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/mutations.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/queries.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/raw-sql.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/relationships.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/transactions.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/multiple-databases.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/pagination.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/soft-deletes.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/testing.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/timestamps.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/index.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/roadmap.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/stylesheets/extra.css +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/why-ferro.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/ir-first-migration-guide.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/ir-first-release-checklist.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/rfc/ir-contracts-v1.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/README.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/ir-invariants.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/justfile +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/scripts/demo_queries_pre_v012.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/backend.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/codec.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/connection.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_deprecations.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/base.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/fields.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/ir/__init__.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/ir/compiler.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/py.typed +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/raw.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/hydration.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/introspect.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/lib.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/migrate.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/query.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/schema.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/schema_bind.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/state.rs +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/__init__.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/conftest.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/db_backends.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/README.md +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/query_user_compound_v1.json +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/postgres.json +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/sqlite.json +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_connection.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_constraints.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_crud.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_deletion.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_deprecated_operator_inventory.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_deprecations.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_enum_cold_hydration.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_helpers.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_hydration.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_ir_vectors_contract.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_metadata.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_migrate_plan.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_models.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_refresh.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_shadow_reports.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_string_search.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_transactions.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_typed_null_binds.py +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/uv.lock +0 -0
- {ferro_orm-0.12.0 → ferro_orm-0.12.2}/zensical.toml +0 -0
|
@@ -90,7 +90,8 @@ If you add a new schema feature (e.g. partial indexes, exclusion constraints):
|
|
|
90
90
|
numbered list above.
|
|
91
91
|
2. Implement it in **both** the Alembic and Rust paths in the same PR.
|
|
92
92
|
3. Add a parity test that asserts the names match.
|
|
93
|
-
4.
|
|
93
|
+
4. Do not edit `CHANGELOG.md` manually — release tooling records entries at
|
|
94
|
+
release time (see I-11).
|
|
94
95
|
|
|
95
96
|
---
|
|
96
97
|
|
|
@@ -276,3 +277,21 @@ Rules:
|
|
|
276
277
|
updates are complete before merge.
|
|
277
278
|
- AI agents and human contributors follow the same requirement. If issue status
|
|
278
279
|
closure is missing, the PR is not done.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## I-11: Do not edit CHANGELOG.md manually
|
|
284
|
+
|
|
285
|
+
`CHANGELOG.md` is updated automatically by the release workflow (semantic
|
|
286
|
+
release / release tooling). Agents and contributors must **not** add,
|
|
287
|
+
reorder, or edit changelog entries in feature, bugfix, or docs PRs.
|
|
288
|
+
|
|
289
|
+
What to do instead:
|
|
290
|
+
|
|
291
|
+
- Describe user-visible changes in the PR title and body.
|
|
292
|
+
- Rely on conventional commit messages and the release process to populate
|
|
293
|
+
`CHANGELOG.md` after merge.
|
|
294
|
+
|
|
295
|
+
If release tooling fails to capture a change, fix the release configuration or
|
|
296
|
+
commit message conventions — do not patch `CHANGELOG.md` by hand in ordinary
|
|
297
|
+
PRs.
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.12.2 (2026-06-23)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Cross-context session teardown (Fixes #123) ([#127](https://github.com/syn54x/ferro-orm/pull/127),
|
|
9
|
+
[`6b9ae13`](https://github.com/syn54x/ferro-orm/commit/6b9ae1332becef88f14bdb4beae1d24e3a4104cd))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## v0.12.1 (2026-06-23)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
- Stop framework predicate warnings and bind unnamed sessions to default
|
|
17
|
+
([#124](https://github.com/syn54x/ferro-orm/pull/124),
|
|
18
|
+
[`66b92d8`](https://github.com/syn54x/ferro-orm/commit/66b92d8b691a1ad806691b9841bcbaf93c78f691))
|
|
19
|
+
|
|
20
|
+
|
|
4
21
|
## v0.12.0 (2026-06-23)
|
|
5
22
|
|
|
6
23
|
### Documentation
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Functions for managing database connections and the global model registry. `connect()` registers a (optionally named) connection pool; `reset_engine()` tears everything down; the registry helpers control schema creation and the identity map. Sessionized routing is exposed via `ferro.engines.session(name)` / `ferro.Session`. See the [Connections & Databases guide](../guide/connections.md).
|
|
4
4
|
|
|
5
|
+
::: ferro.Session
|
|
6
|
+
|
|
5
7
|
::: ferro.connect
|
|
6
8
|
|
|
7
9
|
::: ferro.PoolConfig
|
|
@@ -114,6 +114,37 @@ async with ferro.engines.session("analytics") as s:
|
|
|
114
114
|
|
|
115
115
|
Inside an active session context, convenience APIs (`User.all()`, `User.where(...)`, `ferro.execute(...)`) automatically bind to that session's connection.
|
|
116
116
|
|
|
117
|
+
### Request-scoped sessions
|
|
118
|
+
|
|
119
|
+
For short units of work (HTTP handlers, scripts, tests), use `async with`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
async with ferro.engines.session("analytics"):
|
|
123
|
+
rows = await User.where(lambda t: t.active == True).all() # noqa: E712
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Long-lived sessions (GUI, app lifecycle)
|
|
127
|
+
|
|
128
|
+
When startup and shutdown run in different asyncio tasks (for example Textual `on_mount` / `on_unmount`), open a session handle at startup and close it explicitly at shutdown:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
class App:
|
|
132
|
+
async def on_mount(self) -> None:
|
|
133
|
+
self.session = ferro.engines.session("default")
|
|
134
|
+
await self.session.__aenter__()
|
|
135
|
+
|
|
136
|
+
async def on_unmount(self) -> None:
|
|
137
|
+
await self.session.close()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`Session.close()` is safe across asyncio contexts and idempotent. Prefer explicit `session=` routing when ambient context may be unavailable in the closing task.
|
|
141
|
+
|
|
142
|
+
After a cross-context close, the enter task may still hold a stale ambient session object until a new session is opened; ambient operations then raise `RuntimeError` with a session-closed message (including when `using=` names another connection). If nested sessions are open, close inner sessions from the same task or via cross-context `close()` before closing the outer handle in the original task.
|
|
143
|
+
|
|
144
|
+
Operations on a closed session handle (ambient or explicit `session=`) raise `RuntimeError` rather than falling back to legacy default routing.
|
|
145
|
+
|
|
146
|
+
`ferro.reset_engine()` tears down **all** connections and is intended for test isolation — not per-session app lifecycle teardown.
|
|
147
|
+
|
|
117
148
|
Legacy implicit default-connection routing (calling unqualified operations outside a session) is still temporarily supported for compatibility, but now emits a deprecation warning and is on the `v0.14.0` removal track.
|
|
118
149
|
Follow [Migrating to v0.12.0](../howto/migrating-to-v0-12-0.md) to remove these legacy call sites during the compatibility window.
|
|
119
150
|
|
|
@@ -72,6 +72,10 @@ active session relied on implicit default-connection routing. That fallback is
|
|
|
72
72
|
deprecated. Wrap request- or task-scoped work in a session so routing, the
|
|
73
73
|
identity map, and transactions are explicit and isolated under concurrency.
|
|
74
74
|
|
|
75
|
+
After a single unnamed `connect(dsn)`, `async with ferro.engines.session()` (no
|
|
76
|
+
argument) binds to that default connection — you do not need to pass
|
|
77
|
+
`"default"` explicitly.
|
|
78
|
+
|
|
75
79
|
=== "Before (deprecated)"
|
|
76
80
|
|
|
77
81
|
```python
|
|
@@ -160,7 +160,7 @@ async def begin_transaction(
|
|
|
160
160
|
async def commit_transaction(tx_id: str, session_id: Optional[str] = None) -> None: ...
|
|
161
161
|
def transaction_connection_name(tx_id: str, session_id: Optional[str] = None) -> str: ...
|
|
162
162
|
async def rollback_transaction(tx_id: str, session_id: Optional[str] = None) -> None: ...
|
|
163
|
-
def open_session(using: Optional[str] = None) -> str: ...
|
|
163
|
+
def open_session(using: Optional[str] = None) -> tuple[str, str]: ...
|
|
164
164
|
def close_session(session_id: str) -> None: ...
|
|
165
165
|
async def raw_execute(
|
|
166
166
|
sql: str,
|
|
@@ -31,7 +31,7 @@ from ._core import (
|
|
|
31
31
|
from .base import ForeignKey, foreign_key_allows_none
|
|
32
32
|
from .exceptions import ModelDoesNotExist
|
|
33
33
|
from .metaclass import ModelMetaclass
|
|
34
|
-
from .query import Query, QueryNode
|
|
34
|
+
from .query import Predicate, Query, QueryNode
|
|
35
35
|
from .state import (
|
|
36
36
|
_CURRENT_TRANSACTION,
|
|
37
37
|
_CURRENT_TRANSACTION_CONNECTION,
|
|
@@ -43,6 +43,11 @@ from .state import (
|
|
|
43
43
|
_FERRO_CONNECTION_ATTR = "__ferro_connection_name"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _field_eq(field_name: str, value: Any) -> Predicate[Any]:
|
|
47
|
+
"""Build a lambda predicate for dynamic field equality without operator style."""
|
|
48
|
+
return lambda t, f=field_name, v=value: getattr(t, f) == v
|
|
49
|
+
|
|
50
|
+
|
|
46
51
|
def _transaction_or_using(
|
|
47
52
|
using: str | None, session: "Session | None"
|
|
48
53
|
) -> tuple[str | None, str | None, str | None]:
|
|
@@ -289,12 +294,10 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
289
294
|
|
|
290
295
|
if pk_val is not None:
|
|
291
296
|
name = self.__class__.__name__
|
|
292
|
-
query = self.__class__.where(
|
|
293
|
-
getattr(self.__class__, pk_field_name) == pk_val
|
|
294
|
-
)
|
|
297
|
+
query = self.__class__.where(_field_eq(pk_field_name, pk_val))
|
|
295
298
|
if operation_using is not None:
|
|
296
299
|
query = Query(self.__class__, using=operation_using).where(
|
|
297
|
-
|
|
300
|
+
_field_eq(pk_field_name, pk_val)
|
|
298
301
|
)
|
|
299
302
|
await query.delete()
|
|
300
303
|
evict_instance(
|
|
@@ -391,7 +394,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
391
394
|
if pk_field_name is None:
|
|
392
395
|
raise RuntimeError(f"Model {cls.__name__} does not define a primary key")
|
|
393
396
|
|
|
394
|
-
instance = await cls.where(
|
|
397
|
+
instance = await cls.where(
|
|
398
|
+
_field_eq(pk_field_name, pk), session=session
|
|
399
|
+
).first()
|
|
395
400
|
if instance:
|
|
396
401
|
cls._fix_types(instance)
|
|
397
402
|
return instance
|
|
@@ -423,10 +428,10 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
423
428
|
)
|
|
424
429
|
|
|
425
430
|
evict_instance(name, str(pk_val), identity_using, session_id=session_id)
|
|
426
|
-
query = self.__class__.where(
|
|
431
|
+
query = self.__class__.where(_field_eq(pk_field_name, pk_val))
|
|
427
432
|
if operation_using is not None:
|
|
428
433
|
query = Query(self.__class__, using=operation_using).where(
|
|
429
|
-
|
|
434
|
+
_field_eq(pk_field_name, pk_val)
|
|
430
435
|
)
|
|
431
436
|
fresh_instance = await query.first()
|
|
432
437
|
|
|
@@ -576,7 +581,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
576
581
|
"""
|
|
577
582
|
query = Query(cls, session=session)
|
|
578
583
|
for key, val in fields.items():
|
|
579
|
-
query = query.where(
|
|
584
|
+
query = query.where(_field_eq(key, val))
|
|
580
585
|
|
|
581
586
|
instance = await query.first()
|
|
582
587
|
if instance:
|
|
@@ -604,7 +609,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
604
609
|
"""
|
|
605
610
|
query = Query(cls, session=session)
|
|
606
611
|
for key, val in fields.items():
|
|
607
|
-
query = query.where(
|
|
612
|
+
query = query.where(_field_eq(key, val))
|
|
608
613
|
|
|
609
614
|
instance = await query.first()
|
|
610
615
|
if instance:
|
|
@@ -662,9 +667,7 @@ class ModelConnection[M: Model]:
|
|
|
662
667
|
f"Model {self.model_cls.__name__} does not define a primary key"
|
|
663
668
|
)
|
|
664
669
|
|
|
665
|
-
instance = await self.where(
|
|
666
|
-
getattr(self.model_cls, pk_field_name) == pk
|
|
667
|
-
).first()
|
|
670
|
+
instance = await self.where(_field_eq(pk_field_name, pk)).first()
|
|
668
671
|
if instance:
|
|
669
672
|
self.model_cls._fix_types(instance)
|
|
670
673
|
return instance
|
|
@@ -677,7 +680,7 @@ class ModelConnection[M: Model]:
|
|
|
677
680
|
) -> tuple[M, bool]:
|
|
678
681
|
query = Query(self.model_cls, using=self._connection_name)
|
|
679
682
|
for key, val in fields.items():
|
|
680
|
-
query = query.where(
|
|
683
|
+
query = query.where(_field_eq(key, val))
|
|
681
684
|
|
|
682
685
|
instance = await query.first()
|
|
683
686
|
if instance:
|
|
@@ -691,7 +694,7 @@ class ModelConnection[M: Model]:
|
|
|
691
694
|
) -> tuple[M, bool]:
|
|
692
695
|
query = Query(self.model_cls, using=self._connection_name)
|
|
693
696
|
for key, val in fields.items():
|
|
694
|
-
query = query.where(
|
|
697
|
+
query = query.where(_field_eq(key, val))
|
|
695
698
|
|
|
696
699
|
instance = await query.first()
|
|
697
700
|
if instance:
|
|
@@ -69,9 +69,10 @@ class RelationshipDescriptor(BaseModel):
|
|
|
69
69
|
|
|
70
70
|
pk_val = getattr(instance, pk_field)
|
|
71
71
|
|
|
72
|
+
fk_field = f"{self.field_name}_id"
|
|
72
73
|
if self.is_one_to_one:
|
|
73
74
|
return self._target_model.where(
|
|
74
|
-
getattr(
|
|
75
|
+
lambda t, f=fk_field, v=pk_val: getattr(t, f) == v
|
|
75
76
|
).first()
|
|
76
77
|
|
|
77
78
|
from ..query.builder import Relation
|
|
@@ -79,9 +80,7 @@ class RelationshipDescriptor(BaseModel):
|
|
|
79
80
|
return Relation(
|
|
80
81
|
self._target_model,
|
|
81
82
|
using=_instance_origin_outside_transaction(instance),
|
|
82
|
-
).where(
|
|
83
|
-
getattr(self._target_model, f"{self.field_name}_id") == pk_val
|
|
84
|
-
)
|
|
83
|
+
).where(lambda t, f=fk_field, v=pk_val: getattr(t, f) == v)
|
|
85
84
|
|
|
86
85
|
|
|
87
86
|
class ForwardDescriptor(BaseModel):
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Session-scoped runtime state for Ferro operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._core import close_session as _core_close_session
|
|
11
|
+
from ._core import open_session as _core_open_session
|
|
12
|
+
from .state import _CURRENT_SESSION
|
|
13
|
+
|
|
14
|
+
_SESSION_CLOSE_AMBIENT_MISMATCH = (
|
|
15
|
+
"Session close failed: ambient session does not match the closing session. "
|
|
16
|
+
"This usually indicates session lifecycle misuse in the current asyncio context."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class Session:
|
|
22
|
+
connection_name: str | None = None
|
|
23
|
+
session_id: str | None = None
|
|
24
|
+
_token: Any = field(default=None, repr=False, compare=False)
|
|
25
|
+
_enter_context: contextvars.Context | None = field(
|
|
26
|
+
default=None, repr=False, compare=False
|
|
27
|
+
)
|
|
28
|
+
_enter_task: asyncio.Task[Any] | None = field(
|
|
29
|
+
default=None, repr=False, compare=False
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def __aenter__(self) -> "Session":
|
|
33
|
+
self.session_id, resolved_name = _core_open_session(self.connection_name)
|
|
34
|
+
if self.connection_name is None:
|
|
35
|
+
self.connection_name = resolved_name
|
|
36
|
+
self._token = _CURRENT_SESSION.set(self)
|
|
37
|
+
self._enter_context = contextvars.copy_context()
|
|
38
|
+
self._enter_task = asyncio.current_task()
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
42
|
+
try:
|
|
43
|
+
await self.close()
|
|
44
|
+
except Exception as close_exc:
|
|
45
|
+
if exc_type is None:
|
|
46
|
+
raise
|
|
47
|
+
if exc is not None:
|
|
48
|
+
raise exc from close_exc
|
|
49
|
+
raise close_exc
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
"""Close this session and release its runtime state.
|
|
53
|
+
|
|
54
|
+
Safe to call from a different asyncio context than ``__aenter__``.
|
|
55
|
+
Repeated calls are no-ops.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
RuntimeError: If the ambient session in this asyncio context does not
|
|
59
|
+
match this handle (same-context lifecycle misuse).
|
|
60
|
+
"""
|
|
61
|
+
if self.session_id is None and self._token is None:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
self._assert_close_allowed()
|
|
65
|
+
|
|
66
|
+
if self.session_id is not None:
|
|
67
|
+
session_id = self.session_id
|
|
68
|
+
_core_close_session(session_id)
|
|
69
|
+
self.session_id = None
|
|
70
|
+
|
|
71
|
+
if self._token is None:
|
|
72
|
+
self._enter_context = None
|
|
73
|
+
self._enter_task = None
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
token = self._token
|
|
77
|
+
self._token = None
|
|
78
|
+
self._enter_context = None
|
|
79
|
+
self._enter_task = None
|
|
80
|
+
self._restore_ambient_session(token)
|
|
81
|
+
|
|
82
|
+
def _assert_close_allowed(self) -> None:
|
|
83
|
+
if self._token is None:
|
|
84
|
+
return
|
|
85
|
+
if asyncio.current_task() is not self._enter_task:
|
|
86
|
+
return
|
|
87
|
+
ambient = _CURRENT_SESSION.get()
|
|
88
|
+
if ambient is self:
|
|
89
|
+
return
|
|
90
|
+
entered_ambient = (
|
|
91
|
+
self._enter_context.get(_CURRENT_SESSION)
|
|
92
|
+
if self._enter_context is not None
|
|
93
|
+
else None
|
|
94
|
+
)
|
|
95
|
+
if entered_ambient is self and ambient is not None:
|
|
96
|
+
raise RuntimeError(_SESSION_CLOSE_AMBIENT_MISMATCH)
|
|
97
|
+
|
|
98
|
+
def _restore_ambient_session(self, token: Any) -> None:
|
|
99
|
+
ambient = _CURRENT_SESSION.get()
|
|
100
|
+
if ambient is not self:
|
|
101
|
+
try:
|
|
102
|
+
_CURRENT_SESSION.reset(token)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return
|
|
105
|
+
raise RuntimeError(_SESSION_CLOSE_AMBIENT_MISMATCH)
|
|
106
|
+
try:
|
|
107
|
+
_CURRENT_SESSION.reset(token)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
def query(self, model_cls):
|
|
112
|
+
from .query import Query
|
|
113
|
+
|
|
114
|
+
return Query(model_cls, session=self)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class EngineManager:
|
|
118
|
+
def session(self, name: str | None = None) -> Session:
|
|
119
|
+
return Session(connection_name=name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
engines = EngineManager()
|
|
@@ -34,6 +34,16 @@ _LEGACY_DEFAULT_CONNECTION_REASON = (
|
|
|
34
34
|
"ORM/raw operations."
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
+
_SESSION_CLOSED_MESSAGE = (
|
|
38
|
+
"Session is closed. Open a new session with "
|
|
39
|
+
"`async with ferro.engines.session(...)` or pass an active session handle."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ensure_active_session(session: SessionLike | None) -> None:
|
|
44
|
+
if session is not None and session.session_id is None:
|
|
45
|
+
raise RuntimeError(_SESSION_CLOSED_MESSAGE)
|
|
46
|
+
|
|
37
47
|
# Global registry for models (Python side)
|
|
38
48
|
_MODEL_REGISTRY_PY = {}
|
|
39
49
|
|
|
@@ -76,6 +86,11 @@ def resolve_operation_scope(
|
|
|
76
86
|
if explicit_session is None and using != effective_session.connection_name:
|
|
77
87
|
effective_session = None
|
|
78
88
|
|
|
89
|
+
if explicit_session is None and ambient_session is not None:
|
|
90
|
+
_ensure_active_session(ambient_session)
|
|
91
|
+
|
|
92
|
+
_ensure_active_session(effective_session)
|
|
93
|
+
|
|
79
94
|
session_id = effective_session.session_id if effective_session is not None else None
|
|
80
95
|
|
|
81
96
|
if tx_id is not None:
|
|
@@ -124,6 +139,11 @@ def resolve_transaction_scope(
|
|
|
124
139
|
if explicit_session is None and using != effective_session.connection_name:
|
|
125
140
|
effective_session = None
|
|
126
141
|
|
|
142
|
+
if explicit_session is None and ambient_session is not None:
|
|
143
|
+
_ensure_active_session(ambient_session)
|
|
144
|
+
|
|
145
|
+
_ensure_active_session(effective_session)
|
|
146
|
+
|
|
127
147
|
if parent_tx_id is not None:
|
|
128
148
|
# Nested tx route is always inherited from parent.
|
|
129
149
|
return parent_tx_id, None, (
|
|
@@ -825,9 +825,10 @@ pub fn rollback_transaction(
|
|
|
825
825
|
|
|
826
826
|
#[pyfunction]
|
|
827
827
|
#[pyo3(signature = (using=None))]
|
|
828
|
-
pub fn open_session(using: Option<String>) -> PyResult<String> {
|
|
828
|
+
pub fn open_session(using: Option<String>) -> PyResult<(String, String)> {
|
|
829
829
|
let (connection_name, _) = active_connection_for_route(using)?;
|
|
830
|
-
|
|
830
|
+
let session_id = register_session(connection_name.clone());
|
|
831
|
+
Ok((session_id, connection_name))
|
|
831
832
|
}
|
|
832
833
|
|
|
833
834
|
#[pyfunction]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Regression tests for framework-internal predicate style (issue #121)."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import ferro
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
pytestmark = pytest.mark.sqlite_only
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FrameworkPredicateMarker(ferro.Model):
|
|
15
|
+
id: Annotated[int | None, ferro.FerroField(primary_key=True)] = None
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def _ensure_models_registered():
|
|
21
|
+
from ferro.state import _MODEL_REGISTRY_PY
|
|
22
|
+
|
|
23
|
+
FrameworkPredicateMarker._reregister_ferro()
|
|
24
|
+
_MODEL_REGISTRY_PY[FrameworkPredicateMarker.__name__] = FrameworkPredicateMarker
|
|
25
|
+
yield
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _operator_predicate_warnings(caught: list[warnings.WarningMessage]) -> list[str]:
|
|
29
|
+
return [
|
|
30
|
+
str(w.message)
|
|
31
|
+
for w in caught
|
|
32
|
+
if issubclass(w.category, DeprecationWarning)
|
|
33
|
+
and "Operator predicate style" in str(w.message)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_framework_helpers_do_not_emit_operator_predicate_warnings(tmp_path):
|
|
39
|
+
db = tmp_path / "framework_predicates.db"
|
|
40
|
+
await ferro.connect(f"sqlite:{db}?mode=rwc", auto_migrate=True)
|
|
41
|
+
|
|
42
|
+
async with ferro.engines.session("default"):
|
|
43
|
+
widget = FrameworkPredicateMarker(id=1, name="demo")
|
|
44
|
+
await widget.save()
|
|
45
|
+
|
|
46
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
47
|
+
warnings.simplefilter("always", DeprecationWarning)
|
|
48
|
+
loaded = await FrameworkPredicateMarker.get_or_none(1)
|
|
49
|
+
assert loaded is not None
|
|
50
|
+
await loaded.refresh()
|
|
51
|
+
found, created = await FrameworkPredicateMarker.get_or_create(name="demo")
|
|
52
|
+
assert found is not None
|
|
53
|
+
assert created is False
|
|
54
|
+
await loaded.delete()
|
|
55
|
+
|
|
56
|
+
assert _operator_predicate_warnings(caught) == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_model_connection_get_or_none_does_not_emit_operator_warnings(tmp_path):
|
|
61
|
+
db = tmp_path / "framework_predicates_bound.db"
|
|
62
|
+
await ferro.connect(f"sqlite:{db}?mode=rwc", auto_migrate=True)
|
|
63
|
+
|
|
64
|
+
async with ferro.engines.session("default"):
|
|
65
|
+
await FrameworkPredicateMarker.create(id=2, name="bound")
|
|
66
|
+
|
|
67
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
68
|
+
warnings.simplefilter("always", DeprecationWarning)
|
|
69
|
+
loaded = await FrameworkPredicateMarker.using("default").get_or_none(2)
|
|
70
|
+
|
|
71
|
+
assert loaded is not None
|
|
72
|
+
assert _operator_predicate_warnings(caught) == []
|