ferro-orm 0.10.3__tar.gz → 0.10.5__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.10.3 → ferro_orm-0.10.5}/CHANGELOG.md +18 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/Cargo.lock +19 -19
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/Cargo.toml +1 -1
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/PKG-INFO +1 -1
- ferro_orm-0.10.5/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +112 -0
- ferro_orm-0.10.5/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +228 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/pyproject.toml +1 -1
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/metaclass.py +20 -1
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/models.py +19 -41
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/lib.rs +1 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/operations.rs +16 -37
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/query.rs +62 -0
- ferro_orm-0.10.5/src/schema_bind.rs +49 -0
- ferro_orm-0.10.5/tests/test_enum_cold_hydration.py +64 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_structural_types.py +39 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.gitignore +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.python-version +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/AGENTS.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/LICENSE +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/README.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/fields.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/model.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/query.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/relationships.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/transactions.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/utilities.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/changelog.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/coming-soon.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/contributing.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/faq.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/backend.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/database.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/queries.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/testing.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/index.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/README.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/why-ferro.md +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/justfile +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/mkdocs.yml +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/backend.rs +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/connection.rs +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/base.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/fields.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/py.typed +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/raw.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/state.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/schema.rs +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/state.rs +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/__init__.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/conftest.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/db_backends.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_connection.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_constraints.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_crud.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_deletion.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_helpers.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_hydration.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_metadata.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_models.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_refresh.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_string_search.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_transactions.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_typed_null_binds.py +0 -0
- {ferro_orm-0.10.3 → ferro_orm-0.10.5}/uv.lock +0 -0
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.10.5 (2026-05-25)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Coerce Annotated StrEnum fields on cold hydration
|
|
9
|
+
([#66](https://github.com/syn54x/ferro-orm/pull/66),
|
|
10
|
+
[`c17c13b`](https://github.com/syn54x/ferro-orm/commit/c17c13b56e100028a42fa431ca59c9729a22daeb))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v0.10.4 (2026-05-24)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- **query**: Cast native Postgres enum RHS in `.where()` filters
|
|
18
|
+
([#64](https://github.com/syn54x/ferro-orm/pull/64),
|
|
19
|
+
[`7fea893`](https://github.com/syn54x/ferro-orm/commit/7fea89320fd2216a956ae32e8ae6f4829dd8fcf7))
|
|
20
|
+
|
|
21
|
+
|
|
4
22
|
## v0.10.3 (2026-05-21)
|
|
5
23
|
|
|
6
24
|
### Bug Fixes
|
|
@@ -25,9 +25,9 @@ dependencies = [
|
|
|
25
25
|
|
|
26
26
|
[[package]]
|
|
27
27
|
name = "autocfg"
|
|
28
|
-
version = "1.5.
|
|
28
|
+
version = "1.5.1"
|
|
29
29
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
30
|
-
checksum = "
|
|
30
|
+
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
31
31
|
|
|
32
32
|
[[package]]
|
|
33
33
|
name = "base64"
|
|
@@ -61,9 +61,9 @@ dependencies = [
|
|
|
61
61
|
|
|
62
62
|
[[package]]
|
|
63
63
|
name = "bumpalo"
|
|
64
|
-
version = "3.20.
|
|
64
|
+
version = "3.20.3"
|
|
65
65
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
66
|
-
checksum = "
|
|
66
|
+
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|
67
67
|
|
|
68
68
|
[[package]]
|
|
69
69
|
name = "byteorder"
|
|
@@ -294,7 +294,7 @@ dependencies = [
|
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "ferro"
|
|
297
|
-
version = "0.10.
|
|
297
|
+
version = "0.10.5"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
|
@@ -711,9 +711,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
|
711
711
|
|
|
712
712
|
[[package]]
|
|
713
713
|
name = "js-sys"
|
|
714
|
-
version = "0.3.
|
|
714
|
+
version = "0.3.99"
|
|
715
715
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
716
|
-
checksum = "
|
|
716
|
+
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
|
717
717
|
dependencies = [
|
|
718
718
|
"cfg-if",
|
|
719
719
|
"futures-util",
|
|
@@ -788,9 +788,9 @@ dependencies = [
|
|
|
788
788
|
|
|
789
789
|
[[package]]
|
|
790
790
|
name = "log"
|
|
791
|
-
version = "0.4.
|
|
791
|
+
version = "0.4.30"
|
|
792
792
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
793
|
-
checksum = "
|
|
793
|
+
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
|
794
794
|
|
|
795
795
|
[[package]]
|
|
796
796
|
name = "md-5"
|
|
@@ -1292,9 +1292,9 @@ dependencies = [
|
|
|
1292
1292
|
|
|
1293
1293
|
[[package]]
|
|
1294
1294
|
name = "serde_json"
|
|
1295
|
-
version = "1.0.
|
|
1295
|
+
version = "1.0.150"
|
|
1296
1296
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1297
|
-
checksum = "
|
|
1297
|
+
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
|
1298
1298
|
dependencies = [
|
|
1299
1299
|
"itoa",
|
|
1300
1300
|
"memchr",
|
|
@@ -1892,9 +1892,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
|
|
1892
1892
|
|
|
1893
1893
|
[[package]]
|
|
1894
1894
|
name = "wasm-bindgen"
|
|
1895
|
-
version = "0.2.
|
|
1895
|
+
version = "0.2.122"
|
|
1896
1896
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1897
|
-
checksum = "
|
|
1897
|
+
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
|
1898
1898
|
dependencies = [
|
|
1899
1899
|
"cfg-if",
|
|
1900
1900
|
"once_cell",
|
|
@@ -1905,9 +1905,9 @@ dependencies = [
|
|
|
1905
1905
|
|
|
1906
1906
|
[[package]]
|
|
1907
1907
|
name = "wasm-bindgen-macro"
|
|
1908
|
-
version = "0.2.
|
|
1908
|
+
version = "0.2.122"
|
|
1909
1909
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1910
|
-
checksum = "
|
|
1910
|
+
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
|
1911
1911
|
dependencies = [
|
|
1912
1912
|
"quote",
|
|
1913
1913
|
"wasm-bindgen-macro-support",
|
|
@@ -1915,9 +1915,9 @@ dependencies = [
|
|
|
1915
1915
|
|
|
1916
1916
|
[[package]]
|
|
1917
1917
|
name = "wasm-bindgen-macro-support"
|
|
1918
|
-
version = "0.2.
|
|
1918
|
+
version = "0.2.122"
|
|
1919
1919
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1920
|
-
checksum = "
|
|
1920
|
+
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
|
1921
1921
|
dependencies = [
|
|
1922
1922
|
"bumpalo",
|
|
1923
1923
|
"proc-macro2",
|
|
@@ -1928,9 +1928,9 @@ dependencies = [
|
|
|
1928
1928
|
|
|
1929
1929
|
[[package]]
|
|
1930
1930
|
name = "wasm-bindgen-shared"
|
|
1931
|
-
version = "0.2.
|
|
1931
|
+
version = "0.2.122"
|
|
1932
1932
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1933
|
-
checksum = "
|
|
1933
|
+
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
|
1934
1934
|
dependencies = [
|
|
1935
1935
|
"unicode-ident",
|
|
1936
1936
|
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-05-25
|
|
3
|
+
topic: annotated-strenum-cold-hydration
|
|
4
|
+
issue: https://github.com/syn54x/ferro-orm/issues/65
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Annotated StrEnum Cold Hydration
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Register enum field types once at model class definition (using the same annotation-unwrapping logic as schema registration), then coerce string values from the database into enum members on every hydration path. This fixes cold fetches for `Annotated[StrEnum, FerroField(db_type="text")]` under PEP 563/649 deferred annotations without duplicating discovery logic in `_fix_types`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Problem Frame
|
|
16
|
+
|
|
17
|
+
Ferro hydrates rows through a zero-copy Rust path that populates instance `__dict__` with raw column values. String-valued columns therefore arrive as `str`, not as the declared Python enum. A post-hydration pass (`Model._fix_types`) is responsible for coercing those strings into enum members.
|
|
18
|
+
|
|
19
|
+
That pass discovers enum fields lazily on first use. Discovery calls `get_type_hints` with the **ferro.models** module namespace, not the user model’s defining module. With `from __future__ import annotations` (common on Python 3.14+), deferred annotation strings never resolve in that namespace; the fallback reads `__annotations__`, which are still strings and do not match `isinstance(hint, type)`. Result: `_enum_fields` stays empty, coercion is skipped, and cold reads return plain `str` even though `__ferro_schema__` already records `enum_type_name` correctly.
|
|
20
|
+
|
|
21
|
+
`create()` in the same process often still yields enum instances because Pydantic validates on construction. The bug surfaces on **cold** `all()` / `get()` / query results after `reset_engine()` or a fresh connection — exactly when apps rely on `.value`, `match`/`case`, or strict `isinstance` checks.
|
|
22
|
+
|
|
23
|
+
Equality with the raw string (`instance.mode == "hourly"`) still works; the failure mode is **type fidelity** and enum APIs, not storage or SQL.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
**Enum registration (canonical, class-definition time)**
|
|
30
|
+
|
|
31
|
+
- R1. Each concrete `Model` subclass exposes a stable `_enum_fields: dict[str, type[Enum]]` populated during metaclass setup (Phase 3), not on first `_fix_types` call.
|
|
32
|
+
- R2. Registration uses the shared `_enum_subclass_from_annotation` helper already used when building `__ferro_schema__`, so `Annotated[...]`, optional unions, and plain enum annotations are handled identically for schema and hydration.
|
|
33
|
+
- R3. The source of truth for which fields are enums is Pydantic’s resolved `model_fields[<name>].annotation` (with `get_type_hints(cls, include_extras=True)` as fallback only when a field annotation is missing), never `get_type_hints` with ferro-internal `globals()` / `locals()`.
|
|
34
|
+
- R4. `_fix_types` performs **coercion only** against the pre-built `_enum_fields` map; it must not re-scan annotations or mutate discovery state on fetch.
|
|
35
|
+
|
|
36
|
+
**Hydration behavior**
|
|
37
|
+
|
|
38
|
+
- R5. After any Rust-backed fetch (`all`, `get`, `fetch_filtered`, query builder `first`/`all`, `ModelConnection.get_or_none`, etc.), every non-null enum column whose stored value is not already an instance of the registered enum class is coerced via `enum_cls(raw_value)` (preserving current tolerant failure behavior for invalid DB values).
|
|
39
|
+
- R6. Cold fetch after `reset_engine()` + reconnect must return enum members, not `str`, for models using `Annotated[StrEnum, FerroField(db_type="text")]` with PEP 563/649 deferred annotations.
|
|
40
|
+
- R7. Behavior for plain `StrEnum` fields (no `Annotated`), native Postgres enum columns, and `IntEnum` with integer storage remains unchanged — no regression in existing round-trip tests.
|
|
41
|
+
|
|
42
|
+
**Testing**
|
|
43
|
+
|
|
44
|
+
- R8. Add an integration regression test that defines a model with `from __future__ import annotations`, `Annotated[StrEnum, FerroField(db_type="text")]`, inserts via `create()`, calls `reset_engine()`, reconnects, fetches via `all()` (or `get`), and asserts `isinstance(field, EnumSubclass)` and `.value` access works.
|
|
45
|
+
- R9. Optionally assert `_enum_fields` is non-empty and includes the field name after class creation (unit-level guard against rediscovering the #65 failure mode).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Acceptance Examples
|
|
50
|
+
|
|
51
|
+
- **AE1 — Cold fetch with deferred annotations**
|
|
52
|
+
Covers: R5, R6, R8
|
|
53
|
+
Given `from __future__ import annotations` and `billing_mode: Annotated[Mode, FerroField(db_type="text")]`, when a row is created with `Mode.HOURLY`, then `reset_engine()` and reconnect, then `Row.all()[0]`, then `type(row.billing_mode)` is `Mode` and `row.billing_mode.value == "hourly"`.
|
|
54
|
+
|
|
55
|
+
- **AE2 — Schema parity unchanged**
|
|
56
|
+
Covers: R2, R7
|
|
57
|
+
Given the same model class, `__ferro_schema__["properties"]["billing_mode"]["enum_type_name"]` remains `"mode"` (lowercased class name) before and after the fix.
|
|
58
|
+
|
|
59
|
+
- **AE3 — Invalid DB value tolerance**
|
|
60
|
+
Covers: R5
|
|
61
|
+
Given a text column containing a string that is not a valid enum member, when fetched, the instance field remains a non-enum value (or coercion fails silently as today) without raising during `_fix_types` — no change to defensive behavior unless separately specified.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Success Criteria
|
|
66
|
+
|
|
67
|
+
- Issue #65 reproduction script exits 0 on main after the fix.
|
|
68
|
+
- New regression test fails on current `main` without the fix and passes with it.
|
|
69
|
+
- No new phantom DDL or cross-emitter drift (hydration-only change).
|
|
70
|
+
- `_enum_fields` discovery logic exists in exactly one conceptual place (metaclass + shared helper), not duplicated in `_fix_types`.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Scope Boundaries
|
|
75
|
+
|
|
76
|
+
**In scope**
|
|
77
|
+
|
|
78
|
+
- Python-side enum registration and post-hydration coercion for all existing fetch entry points.
|
|
79
|
+
- Regression test under PEP 563/649 + `Annotated` + `db_type="text"`.
|
|
80
|
+
- Closes GitHub issue #65.
|
|
81
|
+
|
|
82
|
+
**Out of scope**
|
|
83
|
+
|
|
84
|
+
- Rust-side enum coercion during dict population (duplicate logic across FFI; defer unless profiling proves Python coercion is a bottleneck).
|
|
85
|
+
- Changing default storage away from native Postgres enums in Alembic (separate product decision; `db_type="text"` on StrEnum remains valid).
|
|
86
|
+
- Pydantic `model_validate` on hydration (violates direct-to-dict / zero-copy invariant I-2).
|
|
87
|
+
- Broader refactors of `_fix_types` for non-enum types (UUID, Decimal, etc.) unless already planned elsewhere.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Key Decisions
|
|
92
|
+
|
|
93
|
+
| Decision | Choice | Rationale |
|
|
94
|
+
|----------|--------|-----------|
|
|
95
|
+
| Where to register enums | Metaclass Phase 3 | Same lifecycle as `ferro_fields` and schema registration; immune to wrong `get_type_hints` namespaces. |
|
|
96
|
+
| Shared helper | Reuse `_enum_subclass_from_annotation` | Schema and hydration must agree on what counts as an enum field (AGENTS.md parity spirit). |
|
|
97
|
+
| `_fix_types` role | Coercion only | Eliminates lazy discovery bug class; cheaper on hot path. |
|
|
98
|
+
| Minimal patch alternative | Repair `_fix_types` discovery only | **Rejected as lesser solve** — fixes #65 but leaves two discovery paths and repeats failure modes for the next annotation shape. |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Dependencies / Assumptions
|
|
103
|
+
|
|
104
|
+
- Pydantic v2 continues to resolve `model_fields[].annotation` to real enum types even when `__annotations__` on the class remain strings under PEP 563.
|
|
105
|
+
- `reset_engine()` remains a valid way to simulate cold identity-map clears in tests.
|
|
106
|
+
- Issue reporter environment (ferro 0.10.3, Python 3.14+, SQLite) matches current test matrix support.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Outstanding Questions
|
|
111
|
+
|
|
112
|
+
- None blocking implementation. If PEP 649-only models without `__annotate_func__` resolution in metaclass appear in the wild, confirm `_resolve_deferred_annotations` still leaves `model_fields` authoritative (spot-check in plan phase).
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "fix: Annotated StrEnum cold hydration (#65)"
|
|
3
|
+
type: fix
|
|
4
|
+
status: completed
|
|
5
|
+
date: 2026-05-25
|
|
6
|
+
origin: docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md
|
|
7
|
+
issue: https://github.com/syn54x/ferro-orm/issues/65
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# fix: Annotated StrEnum cold hydration (#65)
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
Populate `Model._enum_fields` at class-definition time in `ModelMetaclass` using Pydantic’s resolved field annotations and `_enum_subclass_from_annotation`, then reduce `Model._fix_types` to coercion-only. Add a regression test that reproduces issue #65 (`from __future__ import annotations`, cold fetch after `reset_engine()`). Closes the gap where schema registration knows about enums but hydration does not.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Problem Frame
|
|
19
|
+
|
|
20
|
+
Cold Rust hydration leaves text-backed enum columns as `str`. `Model._fix_types` should coerce them back to enum members, but its lazy discovery uses `get_type_hints(cls, globalns=globals(), localns=locals())` inside `ferro.models` — the wrong namespace. Under PEP 563 (`from __future__ import annotations`), that raises `NameError` for `Annotated`, falls back to string `__annotations__`, and never populates `_enum_fields`. Schema registration already succeeds because `build_model_schema` resolves hints against the model’s defining module and uses `_enum_subclass_from_annotation`.
|
|
21
|
+
|
|
22
|
+
Origin: `docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md` (issue #65).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Requirements Traceability
|
|
27
|
+
|
|
28
|
+
| ID | Requirement | Plan unit |
|
|
29
|
+
|----|-------------|-----------|
|
|
30
|
+
| R1 | Stable `_enum_fields` at class definition | U1 |
|
|
31
|
+
| R2 | Shared `_enum_subclass_from_annotation` | U1 |
|
|
32
|
+
| R3 | Source: `model_fields[].annotation` (+ hints fallback) | U1 |
|
|
33
|
+
| R4 | `_fix_types` coercion-only | U2 |
|
|
34
|
+
| R5 | All fetch paths coerce | U2 (verify call sites) |
|
|
35
|
+
| R6 | Cold fetch + PEP 563/649 | U3 |
|
|
36
|
+
| R7 | No regression on existing enum tests | U3 |
|
|
37
|
+
| R8 | New integration regression | U3 |
|
|
38
|
+
| R9 | Optional `_enum_fields` unit guard | U3 |
|
|
39
|
+
|
|
40
|
+
**Acceptance examples:** AE1 (cold fetch), AE2 (schema unchanged), AE3 (invalid DB value tolerance unchanged).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Scope Boundaries
|
|
45
|
+
|
|
46
|
+
- Python registration + `_fix_types` only; no Rust hydration changes.
|
|
47
|
+
- No Pydantic `model_validate` on fetch (I-2 zero-copy hydration).
|
|
48
|
+
- No changes to DDL / `enum_type_name` emission (already correct).
|
|
49
|
+
|
|
50
|
+
**Rejected lesser approach:** Patching only `_fix_types` to call `get_type_hints(cls, include_extras=True)` without ferro `globals()` — fixes #65 but preserves duplicate discovery and drifts from schema logic. Document in PR if useful; do not implement unless explicitly requested.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Context & Research
|
|
55
|
+
|
|
56
|
+
### Root cause (verified)
|
|
57
|
+
|
|
58
|
+
Reproduction on `main`:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
# with from __future__ import annotations
|
|
62
|
+
billing_mode type: str
|
|
63
|
+
Row._enum_fields: {}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`get_type_hints(Row, globalns=ferro.models.__dict__)` → `NameError: name 'Annotated' is not defined`.
|
|
67
|
+
`get_type_hints(Row, include_extras=True)` (default module) → resolves `Mode`.
|
|
68
|
+
`Row.model_fields['billing_mode'].annotation` → `<enum 'Mode'>` even when `__annotations__` is a string.
|
|
69
|
+
|
|
70
|
+
### Call sites that invoke `_fix_types` (must remain covered)
|
|
71
|
+
|
|
72
|
+
- `src/ferro/models.py`: `all`, `get`, instance method path, `ModelConnection.get_or_none`
|
|
73
|
+
- `src/ferro/query/builder.py`: query result hydration
|
|
74
|
+
|
|
75
|
+
No new call sites required if coercion map is populated at import.
|
|
76
|
+
|
|
77
|
+
### Patterns to follow
|
|
78
|
+
|
|
79
|
+
- `src/ferro/schema_metadata.py` — `_enum_subclass_from_annotation`, `build_model_schema` enum loop (lines ~144–159)
|
|
80
|
+
- `src/ferro/metaclass.py` — Phase 3 post-creation hooks (`_validate_db_type_options` already uses `get_type_hints(cls, include_extras=True)`)
|
|
81
|
+
- `tests/test_schema_enum_annotations.py` — schema-only coverage for deferred annotations; extend or sibling file for hydration
|
|
82
|
+
|
|
83
|
+
### Institutional learnings
|
|
84
|
+
|
|
85
|
+
- `docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md` — hydration path must stay observationally equivalent to Pydantic instances; enum coercion is separate but same “post-Rust normalization” layer.
|
|
86
|
+
- AGENTS.md I-2 — do not route hydration through `Model.__init__`.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Key Technical Decisions
|
|
91
|
+
|
|
92
|
+
| Decision | Choice | Rationale |
|
|
93
|
+
|----------|--------|-----------|
|
|
94
|
+
| Registration timing | Metaclass Phase 3, before/after schema generation | Same lifecycle as `ferro_fields`; map available before any fetch. |
|
|
95
|
+
| Annotation source | `cls.model_fields` first | Already resolved by Pydantic; works for PEP 563 string `__annotations__`. |
|
|
96
|
+
| Helper | Import `_enum_subclass_from_annotation` from `schema_metadata` | Single definition for schema + hydration (R2). |
|
|
97
|
+
| `_fix_types` | Remove lazy discovery block; assume `_enum_fields` exists | `Model` base can set `_enum_fields = {}`; subclasses override at definition. |
|
|
98
|
+
| Test placement | New `tests/test_enum_cold_hydration.py` | Keeps `test_schema_enum_annotations.py` schema-focused; cold path is integration-shaped. |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Open Questions
|
|
103
|
+
|
|
104
|
+
### Resolved
|
|
105
|
+
|
|
106
|
+
- **Why does schema work but hydration fails?** Different `get_type_hints` namespaces and fallback to raw string annotations.
|
|
107
|
+
- **Is Rust coercion needed?** No for this fix; Python post-pass is established pattern and cheap relative to FFI complexity.
|
|
108
|
+
|
|
109
|
+
### Deferred to implementation
|
|
110
|
+
|
|
111
|
+
- Whether to initialize `Model._enum_fields = {}` on the base `Model` class explicitly (recommended for `hasattr` simplification).
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Implementation Units
|
|
116
|
+
|
|
117
|
+
### U1. Register `_enum_fields` in metaclass
|
|
118
|
+
|
|
119
|
+
**Goal:** Every concrete model class has `cls._enum_fields: dict[str, type[Enum]]` before any query runs.
|
|
120
|
+
|
|
121
|
+
**Requirements:** R1, R2, R3.
|
|
122
|
+
|
|
123
|
+
**Dependencies:** None.
|
|
124
|
+
|
|
125
|
+
**Files:**
|
|
126
|
+
|
|
127
|
+
- Modify: `src/ferro/metaclass.py`
|
|
128
|
+
- Optional import: `src/ferro/schema_metadata.py` (existing `_enum_subclass_from_annotation`)
|
|
129
|
+
|
|
130
|
+
**Approach:**
|
|
131
|
+
|
|
132
|
+
1. Add `@staticmethod def _register_enum_fields(cls) -> None` on `ModelMetaclass`.
|
|
133
|
+
2. Iterate `cls.model_fields.items()`; for each `field_name`, `annotation = finfo.annotation`.
|
|
134
|
+
3. Optional fallback per field: if annotation is a `str` or unresolved, try `get_type_hints(cls, include_extras=True).get(field_name)` (same pattern as `_validate_db_type_options`).
|
|
135
|
+
4. `enum_cls = _enum_subclass_from_annotation(annotation)`; if not `None`, add to local dict.
|
|
136
|
+
5. Assign `cls._enum_fields = mapping` (empty dict when no enums).
|
|
137
|
+
6. Call from `__new__` Phase 3 after `super().__new__` and before or after `_generate_and_register_schema` (order irrelevant; both need resolved `model_fields`).
|
|
138
|
+
|
|
139
|
+
**Test scenarios (U3):**
|
|
140
|
+
|
|
141
|
+
- After class body definition with `from __future__ import annotations` and `Annotated[StrEnum, FerroField(...)]`, `ModelSubclass._enum_fields` contains the field name and enum class.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### U2. Simplify `_fix_types` to coercion-only
|
|
146
|
+
|
|
147
|
+
**Goal:** Remove broken lazy discovery; coerce using pre-built map.
|
|
148
|
+
|
|
149
|
+
**Requirements:** R4, R5.
|
|
150
|
+
|
|
151
|
+
**Dependencies:** U1.
|
|
152
|
+
|
|
153
|
+
**Files:**
|
|
154
|
+
|
|
155
|
+
- Modify: `src/ferro/models.py`
|
|
156
|
+
|
|
157
|
+
**Approach:**
|
|
158
|
+
|
|
159
|
+
1. On base `Model`, set class attribute `_enum_fields: ClassVar[dict[str, type[Enum]]] = {}` (or document that only subclasses get populated).
|
|
160
|
+
2. Replace `_fix_types` body:
|
|
161
|
+
- Remove `if not hasattr(cls, "_enum_fields")` discovery block entirely.
|
|
162
|
+
- Loop `for field_name, enum_cls in cls._enum_fields.items():` with existing coercion (`enum_cls(val)` on non-enum non-None values).
|
|
163
|
+
3. Grep for `_enum_fields` mutations elsewhere; there should be none after U1.
|
|
164
|
+
|
|
165
|
+
**Test scenarios:**
|
|
166
|
+
|
|
167
|
+
- Covered by U3 integration test (exercises `all()` → `_fix_types`).
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### U3. Regression tests
|
|
172
|
+
|
|
173
|
+
**Goal:** Fail on current `main`; pass after U1+U2. Guard AE2/AE3.
|
|
174
|
+
|
|
175
|
+
**Requirements:** R6–R9, AE1–AE3.
|
|
176
|
+
|
|
177
|
+
**Dependencies:** U1, U2.
|
|
178
|
+
|
|
179
|
+
**Files:**
|
|
180
|
+
|
|
181
|
+
- Create: `tests/test_enum_cold_hydration.py`
|
|
182
|
+
|
|
183
|
+
**Approach:**
|
|
184
|
+
|
|
185
|
+
1. **AE1 / R6 / R8** — `test_annotated_strenum_text_cold_fetch_after_reset_engine(db_url)`:
|
|
186
|
+
- Module-level `from __future__ import annotations`.
|
|
187
|
+
- Inner model: `billing_mode: Annotated[Mode, FerroField(db_type="text")]`.
|
|
188
|
+
- `connect`, `create` with enum member, `reset_engine`, `connect`, `all()[0]`.
|
|
189
|
+
- Assert `isinstance(..., Mode)`, `.value == "hourly"`.
|
|
190
|
+
2. **AE2 / R7** — In same test or sibling: assert `__ferro_schema__["properties"]["billing_mode"]["enum_type_name"] == "mode"`.
|
|
191
|
+
3. **R9** — `test_enum_fields_populated_for_deferred_annotations`: after class definition, `assert Model._enum_fields["billing_mode"] is Mode` (no DB).
|
|
192
|
+
4. Run existing enum-related tests: `tests/test_db_type_integration.py::test_strenum_text_storage_round_trip`, `tests/test_schema_enum_annotations.py`, `tests/test_structural_types.py` enum cases.
|
|
193
|
+
|
|
194
|
+
**Execution posture:** Test-first — run new test before U1/U2 to confirm failure mode (`str`, empty `_enum_fields`).
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Sequencing
|
|
199
|
+
|
|
200
|
+
```text
|
|
201
|
+
U3 (write failing test) → U1 (metaclass registration) → U2 (_fix_types) → U3 (green) → full pytest enum subset
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Verification Checklist
|
|
207
|
+
|
|
208
|
+
- [ ] `uv run pytest tests/test_enum_cold_hydration.py -q`
|
|
209
|
+
- [ ] Issue #65 repro script (inline or committed under `tests/` / `scripts/`) exits 0
|
|
210
|
+
- [ ] `uv run pytest tests/test_schema_enum_annotations.py tests/test_db_type_integration.py -k enum -q` (or full file if fast)
|
|
211
|
+
- [ ] No `get_type_hints(..., globalns=globals())` left in `_fix_types`
|
|
212
|
+
- [ ] PR body: `Fixes #65`
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Risks
|
|
217
|
+
|
|
218
|
+
| Risk | Mitigation |
|
|
219
|
+
|------|------------|
|
|
220
|
+
| Models defined before imports complete | Same as today for schema; `model_fields` is authoritative. |
|
|
221
|
+
| Circular import metaclass ↔ schema_metadata | Already imports `build_model_schema`; adding enum helper is safe. |
|
|
222
|
+
| Subclass redefines fields dynamically | Out of scope; Ferro models are static class definitions. |
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Handoff to implementation
|
|
227
|
+
|
|
228
|
+
Use `ce-work` or manual implementation following unit order above. Estimated touch surface: ~40 lines metaclass, ~25 lines models, ~60 lines test.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import types
|
|
3
|
+
from enum import Enum
|
|
3
4
|
from typing import (
|
|
4
5
|
Annotated,
|
|
5
6
|
Any,
|
|
@@ -26,7 +27,7 @@ from .base import FerroField, ForeignKey, ManyToManyRelation
|
|
|
26
27
|
from .fields import FERRO_FIELD_EXTRA_KEY
|
|
27
28
|
from .query import FieldProxy, Relation
|
|
28
29
|
from .relations.descriptors import ForwardDescriptor
|
|
29
|
-
from .schema_metadata import build_model_schema
|
|
30
|
+
from .schema_metadata import _enum_subclass_from_annotation, build_model_schema
|
|
30
31
|
from .state import _MODEL_REGISTRY_PY, _PENDING_RELATIONS
|
|
31
32
|
|
|
32
33
|
|
|
@@ -57,6 +58,7 @@ class ModelMetaclass(type(BaseModel)):
|
|
|
57
58
|
ferro_fields = mcs._parse_ferro_field_metadata(cls)
|
|
58
59
|
cls.ferro_fields = ferro_fields
|
|
59
60
|
mcs._validate_db_type_options(cls, ferro_fields)
|
|
61
|
+
mcs._register_enum_fields(cls)
|
|
60
62
|
mcs._inject_relation_descriptors(cls, local_relations)
|
|
61
63
|
mcs._generate_and_register_schema(cls, name, ferro_fields, local_relations)
|
|
62
64
|
|
|
@@ -397,6 +399,23 @@ class ModelMetaclass(type(BaseModel)):
|
|
|
397
399
|
|
|
398
400
|
return ferro_fields
|
|
399
401
|
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _register_enum_fields(cls) -> None:
|
|
404
|
+
"""Populate ``cls._enum_fields`` from resolved Pydantic field annotations."""
|
|
405
|
+
enum_fields: dict[str, type[Enum]] = {}
|
|
406
|
+
try:
|
|
407
|
+
resolved = get_type_hints(cls, include_extras=True)
|
|
408
|
+
except Exception:
|
|
409
|
+
resolved = {}
|
|
410
|
+
for field_name, finfo in getattr(cls, "model_fields", {}).items():
|
|
411
|
+
annotation = finfo.annotation
|
|
412
|
+
if isinstance(annotation, str):
|
|
413
|
+
annotation = resolved.get(field_name, annotation)
|
|
414
|
+
enum_cls = _enum_subclass_from_annotation(annotation)
|
|
415
|
+
if enum_cls is not None:
|
|
416
|
+
enum_fields[field_name] = enum_cls
|
|
417
|
+
cls._enum_fields = enum_fields
|
|
418
|
+
|
|
400
419
|
@staticmethod
|
|
401
420
|
def _validate_db_type_options(cls, ferro_fields: dict) -> None:
|
|
402
421
|
"""Strict validation of ``Field(db_type=..., db_check=...)`` combinations.
|
|
@@ -8,9 +8,6 @@ from typing import (
|
|
|
8
8
|
Any,
|
|
9
9
|
ClassVar,
|
|
10
10
|
Self,
|
|
11
|
-
get_args,
|
|
12
|
-
get_origin,
|
|
13
|
-
get_type_hints,
|
|
14
11
|
overload,
|
|
15
12
|
)
|
|
16
13
|
|
|
@@ -46,7 +43,9 @@ def _transaction_or_using(using: str | None) -> tuple[str | None, str | None]:
|
|
|
46
43
|
tx_connection = _CURRENT_TRANSACTION_CONNECTION.get()
|
|
47
44
|
if using == tx_connection:
|
|
48
45
|
return tx_id, None
|
|
49
|
-
raise ValueError(
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"ORM operations inside a transaction inherit the transaction connection"
|
|
48
|
+
)
|
|
50
49
|
return tx_id, using
|
|
51
50
|
|
|
52
51
|
|
|
@@ -63,7 +62,9 @@ def _instance_transaction_route(
|
|
|
63
62
|
if using is not None:
|
|
64
63
|
if using == tx_connection:
|
|
65
64
|
return tx_id, None, origin or tx_connection
|
|
66
|
-
raise ValueError(
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"ORM operations inside a transaction inherit the transaction connection"
|
|
67
|
+
)
|
|
67
68
|
return tx_id, None, origin or tx_connection
|
|
68
69
|
|
|
69
70
|
effective_using = using or origin
|
|
@@ -155,6 +156,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
155
156
|
|
|
156
157
|
__ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ()
|
|
157
158
|
__ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ()
|
|
159
|
+
_enum_fields: ClassVar[dict[str, type[Enum]]] = {}
|
|
158
160
|
|
|
159
161
|
@classmethod
|
|
160
162
|
def _reregister_ferro(cls) -> None:
|
|
@@ -224,7 +226,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
224
226
|
>>> user = User(name="Taylor")
|
|
225
227
|
>>> await user.save()
|
|
226
228
|
"""
|
|
227
|
-
tx_id, operation_using, identity_using = _instance_transaction_route(
|
|
229
|
+
tx_id, operation_using, identity_using = _instance_transaction_route(
|
|
230
|
+
self, using
|
|
231
|
+
)
|
|
228
232
|
new_id = await save_record(
|
|
229
233
|
self.__class__.__name__, self.model_dump_json(), tx_id, operation_using
|
|
230
234
|
)
|
|
@@ -251,7 +255,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
251
255
|
break
|
|
252
256
|
|
|
253
257
|
if pk_val is not None:
|
|
254
|
-
register_instance(
|
|
258
|
+
register_instance(
|
|
259
|
+
self.__class__.__name__, str(pk_val), self, identity_using
|
|
260
|
+
)
|
|
255
261
|
_set_instance_origin(self, identity_using)
|
|
256
262
|
|
|
257
263
|
async def delete(self, *, using: str | None = None) -> None:
|
|
@@ -267,7 +273,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
267
273
|
"""
|
|
268
274
|
pk_field_name = self.__class__._primary_key_field_name()
|
|
269
275
|
pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
|
|
270
|
-
_tx_id, operation_using, identity_using = _instance_transaction_route(
|
|
276
|
+
_tx_id, operation_using, identity_using = _instance_transaction_route(
|
|
277
|
+
self, using
|
|
278
|
+
)
|
|
271
279
|
|
|
272
280
|
if pk_val is not None:
|
|
273
281
|
name = self.__class__.__name__
|
|
@@ -303,38 +311,6 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
303
311
|
Returns:
|
|
304
312
|
None
|
|
305
313
|
"""
|
|
306
|
-
if not hasattr(cls, "_enum_fields"):
|
|
307
|
-
cls._enum_fields = {}
|
|
308
|
-
try:
|
|
309
|
-
hints = get_type_hints(cls, globalns=globals(), localns=locals())
|
|
310
|
-
for field_name, hint in hints.items():
|
|
311
|
-
actual_type = hint
|
|
312
|
-
origin = get_origin(hint)
|
|
313
|
-
from typing import Union as TypingUnion
|
|
314
|
-
|
|
315
|
-
if origin is TypingUnion:
|
|
316
|
-
args = get_args(hint)
|
|
317
|
-
for arg in args:
|
|
318
|
-
try:
|
|
319
|
-
if isinstance(arg, type) and issubclass(arg, Enum):
|
|
320
|
-
actual_type = arg
|
|
321
|
-
break
|
|
322
|
-
except TypeError:
|
|
323
|
-
pass
|
|
324
|
-
|
|
325
|
-
try:
|
|
326
|
-
if isinstance(actual_type, type) and issubclass(
|
|
327
|
-
actual_type, Enum
|
|
328
|
-
):
|
|
329
|
-
cls._enum_fields[field_name] = actual_type
|
|
330
|
-
except TypeError:
|
|
331
|
-
pass
|
|
332
|
-
except Exception:
|
|
333
|
-
for field_name, hint in getattr(cls, "__annotations__", {}).items():
|
|
334
|
-
if field_name not in cls._enum_fields:
|
|
335
|
-
if isinstance(hint, type) and issubclass(hint, Enum):
|
|
336
|
-
cls._enum_fields[field_name] = hint
|
|
337
|
-
|
|
338
314
|
for field_name, enum_cls in cls._enum_fields.items():
|
|
339
315
|
val = getattr(instance, field_name)
|
|
340
316
|
if val is not None and not isinstance(val, enum_cls):
|
|
@@ -424,7 +400,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
|
|
|
424
400
|
raise RuntimeError("Cannot refresh a model without a primary key")
|
|
425
401
|
|
|
426
402
|
name = self.__class__.__name__
|
|
427
|
-
_tx_id, operation_using, identity_using = _instance_transaction_route(
|
|
403
|
+
_tx_id, operation_using, identity_using = _instance_transaction_route(
|
|
404
|
+
self, using
|
|
405
|
+
)
|
|
428
406
|
|
|
429
407
|
evict_instance(name, str(pk_val), identity_using)
|
|
430
408
|
query = self.__class__.where(getattr(self.__class__, pk_field_name) == pk_val)
|