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.
Files changed (173) hide show
  1. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/CHANGELOG.md +18 -0
  2. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/Cargo.lock +19 -19
  3. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/Cargo.toml +1 -1
  4. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/PKG-INFO +1 -1
  5. ferro_orm-0.10.5/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +112 -0
  6. ferro_orm-0.10.5/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +228 -0
  7. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/pyproject.toml +1 -1
  8. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/metaclass.py +20 -1
  9. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/models.py +19 -41
  10. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/lib.rs +1 -0
  11. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/operations.rs +16 -37
  12. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/query.rs +62 -0
  13. ferro_orm-0.10.5/src/schema_bind.rs +49 -0
  14. ferro_orm-0.10.5/tests/test_enum_cold_hydration.py +64 -0
  15. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_structural_types.py +39 -0
  16. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/PERMISSIONS.md +0 -0
  19. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/PYPI_CHECKLIST.md +0 -0
  20. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/PYPI_SETUP.md +0 -0
  21. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/generated/wheels.generated.yml +0 -0
  22. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/pull_request_template.md +0 -0
  23. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/ci.yml +0 -0
  24. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/packaging-smoke.yml +0 -0
  25. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/publish-docs.yml +0 -0
  26. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/publish.yml +0 -0
  27. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.github/workflows/release.yml +0 -0
  28. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.gitignore +0 -0
  29. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.pre-commit-config.yaml +0 -0
  30. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/.python-version +0 -0
  31. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/AGENTS.md +0 -0
  32. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/CONTRIBUTING.md +0 -0
  33. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/LICENSE +0 -0
  34. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/README.md +0 -0
  35. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/TEST_RESULTS.md +0 -0
  36. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/exceptions.md +0 -0
  37. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/fields.md +0 -0
  38. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/model.md +0 -0
  39. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/query.md +0 -0
  40. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/raw-sql.md +0 -0
  41. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/relationships.md +0 -0
  42. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/transactions.md +0 -0
  43. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/api/utilities.md +0 -0
  44. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  45. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  46. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  47. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  48. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/changelog.md +0 -0
  49. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/coming-soon.md +0 -0
  50. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/architecture.md +0 -0
  51. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/identity-map.md +0 -0
  52. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/performance.md +0 -0
  53. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/query-typing.md +0 -0
  54. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/concepts/type-safety.md +0 -0
  55. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/contributing.md +0 -0
  56. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/faq.md +0 -0
  57. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/getting-started/installation.md +0 -0
  58. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/getting-started/next-steps.md +0 -0
  59. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/getting-started/tutorial.md +0 -0
  60. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/backend.md +0 -0
  61. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/database.md +0 -0
  62. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/migrations.md +0 -0
  63. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/models-and-fields.md +0 -0
  64. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/mutations.md +0 -0
  65. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/queries.md +0 -0
  66. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/relationships.md +0 -0
  67. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/guide/transactions.md +0 -0
  68. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/multiple-databases.md +0 -0
  69. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/pagination.md +0 -0
  70. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/soft-deletes.md +0 -0
  71. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/testing.md +0 -0
  72. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/howto/timestamps.md +0 -0
  73. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/index.md +0 -0
  74. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/migration-sqlalchemy.md +0 -0
  75. {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
  76. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  77. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  78. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  79. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  80. {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
  81. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/README.md +0 -0
  82. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  83. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  84. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  85. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  86. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  87. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  88. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  89. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  90. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/foreign-key-index.md +0 -0
  91. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  92. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  93. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/solutions/patterns/typed-null-binds.md +0 -0
  94. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/stylesheets/extra.css +0 -0
  95. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/docs/why-ferro.md +0 -0
  96. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/justfile +0 -0
  97. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/mkdocs.yml +0 -0
  98. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/scripts/demo_queries.py +0 -0
  99. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/backend.rs +0 -0
  100. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/connection.rs +0 -0
  101. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/__init__.py +0 -0
  102. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/_annotation_utils.py +0 -0
  103. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/_core.pyi +0 -0
  104. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/_shadow_fk_types.py +0 -0
  105. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/base.py +0 -0
  106. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/composite_indexes.py +0 -0
  107. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/composite_uniques.py +0 -0
  108. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/exceptions.py +0 -0
  109. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/fields.py +0 -0
  110. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/migrations/__init__.py +0 -0
  111. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/migrations/alembic.py +0 -0
  112. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/py.typed +0 -0
  113. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/query/__init__.py +0 -0
  114. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/query/builder.py +0 -0
  115. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/query/nodes.py +0 -0
  116. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/raw.py +0 -0
  117. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/relations/__init__.py +0 -0
  118. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/relations/descriptors.py +0 -0
  119. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/schema_metadata.py +0 -0
  120. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/ferro/state.py +0 -0
  121. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/schema.rs +0 -0
  122. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/src/state.rs +0 -0
  123. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/__init__.py +0 -0
  124. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/conftest.py +0 -0
  125. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/db_backends.py +0 -0
  126. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_aggregation.py +0 -0
  127. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_autogenerate.py +0 -0
  128. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_bridge.py +0 -0
  129. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_db_type.py +0 -0
  130. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_nullability.py +0 -0
  131. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_alembic_type_mapping.py +0 -0
  132. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_auto_migrate.py +0 -0
  133. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_bulk_update.py +0 -0
  134. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_composite_index.py +0 -0
  135. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_composite_unique.py +0 -0
  136. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_connection.py +0 -0
  137. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_connection_redaction.py +0 -0
  138. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_constraints.py +0 -0
  139. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_cross_emitter_parity.py +0 -0
  140. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_crud.py +0 -0
  141. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_backends.py +0 -0
  142. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_cross_emitter_parity.py +0 -0
  143. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_integration.py +0 -0
  144. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_typing.py +0 -0
  145. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_db_type_validation.py +0 -0
  146. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_deletion.py +0 -0
  147. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_docs_examples.py +0 -0
  148. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_documentation_features.py +0 -0
  149. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_field_wrapper.py +0 -0
  150. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_helpers.py +0 -0
  151. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_hydration.py +0 -0
  152. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_metaclass_internals.py +0 -0
  153. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_metadata.py +0 -0
  154. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_models.py +0 -0
  155. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_named_connections_integration.py +0 -0
  156. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_one_to_one.py +0 -0
  157. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_query_builder.py +0 -0
  158. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_query_typing.py +0 -0
  159. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_raw_sql.py +0 -0
  160. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_refresh.py +0 -0
  161. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_relationship_engine.py +0 -0
  162. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema.py +0 -0
  163. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema_constraints.py +0 -0
  164. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema_db_type_metadata.py +0 -0
  165. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_schema_enum_annotations.py +0 -0
  166. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_shadow_fk_types.py +0 -0
  167. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  168. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_static_contracts.py +0 -0
  169. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_string_search.py +0 -0
  170. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_temporal_types.py +0 -0
  171. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_transactions.py +0 -0
  172. {ferro_orm-0.10.3 → ferro_orm-0.10.5}/tests/test_typed_null_binds.py +0 -0
  173. {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.0"
28
+ version = "1.5.1"
29
29
  source = "registry+https://github.com/rust-lang/crates.io-index"
30
- checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
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.2"
64
+ version = "3.20.3"
65
65
  source = "registry+https://github.com/rust-lang/crates.io-index"
66
- checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
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.3"
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.98"
714
+ version = "0.3.99"
715
715
  source = "registry+https://github.com/rust-lang/crates.io-index"
716
- checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
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.29"
791
+ version = "0.4.30"
792
792
  source = "registry+https://github.com/rust-lang/crates.io-index"
793
- checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
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.149"
1295
+ version = "1.0.150"
1296
1296
  source = "registry+https://github.com/rust-lang/crates.io-index"
1297
- checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
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.121"
1895
+ version = "0.2.122"
1896
1896
  source = "registry+https://github.com/rust-lang/crates.io-index"
1897
- checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
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.121"
1908
+ version = "0.2.122"
1909
1909
  source = "registry+https://github.com/rust-lang/crates.io-index"
1910
- checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
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.121"
1918
+ version = "0.2.122"
1919
1919
  source = "registry+https://github.com/rust-lang/crates.io-index"
1920
- checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
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.121"
1931
+ version = "0.2.122"
1932
1932
  source = "registry+https://github.com/rust-lang/crates.io-index"
1933
- checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
1933
+ checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
1934
1934
  dependencies = [
1935
1935
  "unicode-ident",
1936
1936
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.10.3"
3
+ version = "0.10.5"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.10.3
3
+ Version: 0.10.5
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -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,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.10.3"
3
+ version = "0.10.5"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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("ORM operations inside a transaction inherit the transaction connection")
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("ORM operations inside a transaction inherit the transaction connection")
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(self, using)
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(self.__class__.__name__, str(pk_val), self, identity_using)
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(self, using)
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(self, using)
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)