ferro-orm 0.9.1__tar.gz → 0.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/AGENTS.md +8 -0
  2. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/CHANGELOG.md +9 -0
  3. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/Cargo.lock +3 -3
  4. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/Cargo.toml +1 -1
  5. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/PKG-INFO +1 -1
  6. ferro_orm-0.9.2/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +46 -0
  7. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/pyproject.toml +1 -1
  8. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/operations.rs +32 -0
  9. ferro_orm-0.9.2/tests/test_hydration.py +100 -0
  10. ferro_orm-0.9.1/tests/test_hydration.py +0 -51
  11. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/PERMISSIONS.md +0 -0
  14. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/PYPI_CHECKLIST.md +0 -0
  15. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/PYPI_SETUP.md +0 -0
  16. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/generated/wheels.generated.yml +0 -0
  17. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/pull_request_template.md +0 -0
  18. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/workflows/ci.yml +0 -0
  19. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/workflows/packaging-smoke.yml +0 -0
  20. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/workflows/publish-docs.yml +0 -0
  21. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/workflows/publish.yml +0 -0
  22. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.github/workflows/release.yml +0 -0
  23. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.gitignore +0 -0
  24. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.pre-commit-config.yaml +0 -0
  25. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/.python-version +0 -0
  26. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/CONTRIBUTING.md +0 -0
  27. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/LICENSE +0 -0
  28. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/README.md +0 -0
  29. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/TEST_RESULTS.md +0 -0
  30. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/exceptions.md +0 -0
  31. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/fields.md +0 -0
  32. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/model.md +0 -0
  33. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/query.md +0 -0
  34. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/raw-sql.md +0 -0
  35. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/relationships.md +0 -0
  36. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/transactions.md +0 -0
  37. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/api/utilities.md +0 -0
  38. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  39. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  40. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/changelog.md +0 -0
  41. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/coming-soon.md +0 -0
  42. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/concepts/architecture.md +0 -0
  43. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/concepts/identity-map.md +0 -0
  44. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/concepts/performance.md +0 -0
  45. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/concepts/query-typing.md +0 -0
  46. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/concepts/type-safety.md +0 -0
  47. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/contributing.md +0 -0
  48. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/faq.md +0 -0
  49. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/getting-started/installation.md +0 -0
  50. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/getting-started/next-steps.md +0 -0
  51. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/getting-started/tutorial.md +0 -0
  52. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/backend.md +0 -0
  53. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/database.md +0 -0
  54. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/migrations.md +0 -0
  55. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/models-and-fields.md +0 -0
  56. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/mutations.md +0 -0
  57. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/queries.md +0 -0
  58. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/relationships.md +0 -0
  59. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/guide/transactions.md +0 -0
  60. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/howto/multiple-databases.md +0 -0
  61. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/howto/pagination.md +0 -0
  62. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/howto/soft-deletes.md +0 -0
  63. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/howto/testing.md +0 -0
  64. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/howto/timestamps.md +0 -0
  65. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/index.md +0 -0
  66. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/migration-sqlalchemy.md +0 -0
  67. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  68. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  69. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  70. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  71. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  72. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/README.md +0 -0
  73. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  74. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  75. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  76. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  77. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
  78. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  79. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  80. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/solutions/patterns/typed-null-binds.md +0 -0
  81. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/stylesheets/extra.css +0 -0
  82. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/docs/why-ferro.md +0 -0
  83. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/justfile +0 -0
  84. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/mkdocs.yml +0 -0
  85. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/scripts/demo_queries.py +0 -0
  86. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/backend.rs +0 -0
  87. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/connection.rs +0 -0
  88. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/__init__.py +0 -0
  89. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/_annotation_utils.py +0 -0
  90. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/_core.pyi +0 -0
  91. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/_shadow_fk_types.py +0 -0
  92. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/base.py +0 -0
  93. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/composite_indexes.py +0 -0
  94. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/composite_uniques.py +0 -0
  95. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/exceptions.py +0 -0
  96. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/fields.py +0 -0
  97. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/metaclass.py +0 -0
  98. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/migrations/__init__.py +0 -0
  99. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/migrations/alembic.py +0 -0
  100. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/models.py +0 -0
  101. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/py.typed +0 -0
  102. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/query/__init__.py +0 -0
  103. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/query/builder.py +0 -0
  104. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/query/nodes.py +0 -0
  105. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/raw.py +0 -0
  106. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/relations/__init__.py +0 -0
  107. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/relations/descriptors.py +0 -0
  108. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/schema_metadata.py +0 -0
  109. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/ferro/state.py +0 -0
  110. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/lib.rs +0 -0
  111. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/query.rs +0 -0
  112. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/schema.rs +0 -0
  113. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/src/state.rs +0 -0
  114. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/__init__.py +0 -0
  115. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/conftest.py +0 -0
  116. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/db_backends.py +0 -0
  117. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_aggregation.py +0 -0
  118. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_alembic_autogenerate.py +0 -0
  119. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_alembic_bridge.py +0 -0
  120. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_alembic_nullability.py +0 -0
  121. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_alembic_type_mapping.py +0 -0
  122. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_auto_migrate.py +0 -0
  123. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_bulk_update.py +0 -0
  124. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_composite_index.py +0 -0
  125. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_composite_unique.py +0 -0
  126. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_connection.py +0 -0
  127. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_connection_redaction.py +0 -0
  128. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_constraints.py +0 -0
  129. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_cross_emitter_parity.py +0 -0
  130. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_crud.py +0 -0
  131. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_db_backends.py +0 -0
  132. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_deletion.py +0 -0
  133. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_docs_examples.py +0 -0
  134. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_documentation_features.py +0 -0
  135. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_field_wrapper.py +0 -0
  136. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_helpers.py +0 -0
  137. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_metaclass_internals.py +0 -0
  138. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_metadata.py +0 -0
  139. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_models.py +0 -0
  140. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_named_connections_integration.py +0 -0
  141. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_one_to_one.py +0 -0
  142. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_query_builder.py +0 -0
  143. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_query_typing.py +0 -0
  144. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_raw_sql.py +0 -0
  145. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_refresh.py +0 -0
  146. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_relationship_engine.py +0 -0
  147. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_schema.py +0 -0
  148. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_schema_constraints.py +0 -0
  149. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_schema_enum_annotations.py +0 -0
  150. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_shadow_fk_types.py +0 -0
  151. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_static_contracts.py +0 -0
  152. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_string_search.py +0 -0
  153. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_structural_types.py +0 -0
  154. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_temporal_types.py +0 -0
  155. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_transactions.py +0 -0
  156. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/tests/test_typed_null_binds.py +0 -0
  157. {ferro_orm-0.9.1 → ferro_orm-0.9.2}/uv.lock +0 -0
@@ -93,6 +93,14 @@ If you add a new schema feature (e.g. partial indexes, exclusion constraints):
93
93
  Python ORMs. The Rust core must populate model dicts directly via the bridge
94
94
  documented in `src/lib.rs` rather than calling `Model(**row)` from Rust.
95
95
 
96
+ Hydrated instances must still be **observationally equivalent** to instances
97
+ constructed through `BaseModel.__init__` for Pydantic’s own slot attributes:
98
+ anything in `BaseModel.__slots__` that `__init__` assigns (notably
99
+ `__pydantic_extra__` and `__pydantic_private__`, in addition to
100
+ `__pydantic_fields_set__`) must be initialized on the Rust hydration path as
101
+ well. Leaving a slot unset raises `AttributeError` on access (unlike a normal
102
+ instance attribute defaulting to `None`).
103
+
96
104
  If you find yourself wanting to call `__init__` from Rust to "make this easier",
97
105
  stop and read `.cursorrules` §3.B and the design notes under
98
106
  `docs/solutions/patterns/`.
@@ -1,6 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.9.2 (2026-05-14)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **hydration**: Initialize Pydantic slots on Rust-hydrated models
9
+ ([#51](https://github.com/syn54x/ferro-orm/pull/51),
10
+ [`7609886`](https://github.com/syn54x/ferro-orm/commit/760988649bbfb41d1e46934cdea589efffdfa1b1))
11
+
12
+
4
13
  ## v0.9.1 (2026-05-11)
5
14
 
6
15
  ### Bug Fixes
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.9.1"
297
+ version = "0.9.2"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -2296,9 +2296,9 @@ dependencies = [
2296
2296
 
2297
2297
  [[package]]
2298
2298
  name = "zerofrom"
2299
- version = "0.1.7"
2299
+ version = "0.1.8"
2300
2300
  source = "registry+https://github.com/rust-lang/crates.io-index"
2301
- checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
2301
+ checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2302
2302
  dependencies = [
2303
2303
  "zerofrom-derive",
2304
2304
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.9.1"
3
+ version = "0.9.2"
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.9.1
3
+ Version: 0.9.2
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,46 @@
1
+ ---
2
+ title: AttributeError on __pydantic_extra__ after loading a row (zero-copy hydration)
3
+ type: issue
4
+ tags: [gotcha, pydantic, bridge, rust, hydration, ffi]
5
+ related_files:
6
+ - src/operations.rs
7
+ - tests/test_hydration.py
8
+ related_issues: []
9
+ related_prs: [51]
10
+ captured: 2026-05-14
11
+ ---
12
+
13
+ ## Problem
14
+
15
+ Anything that touches Pydantic’s slot-backed internals on a model instance fails
16
+ with `AttributeError: ... has no attribute '__pydantic_extra__'` (or
17
+ `__pydantic_private__`) **after** the instance was hydrated by Ferro’s Rust
18
+ core (for example `await Model.get(...)`, filtered query results), while the
19
+ same code works if the instance was built with `Model(...)`.
20
+
21
+ Typical stack traces include `dict(instance)` / `BaseModel.__iter__`, or
22
+ third-party code that walks return values (for example Prefect’s
23
+ `visit_collection`).
24
+
25
+ ## Takeaway
26
+
27
+ Ferro intentionally bypasses `Model.__init__` for performance (see AGENTS.md
28
+ I-2). Pydantic v2 stores several attributes in `__slots__`; **unset** slots do
29
+ not behave like missing dict keys — reads raise `AttributeError`. The Rust
30
+ hydration paths must assign the same defaults `BaseModel.__init__` would,
31
+ including `__pydantic_extra__` (empty dict when `model_config["extra"] ==
32
+ "allow"`, otherwise `None`) and `__pydantic_private__` (`None`).
33
+
34
+ ## Explanation
35
+
36
+ The fix lives next to the existing `__pydantic_fields_set__` assignment in
37
+ `src/operations.rs` (`set_pydantic_hydration_slots`).
38
+
39
+ ## How to recognize
40
+
41
+ - Failure only on **fetched** / **query-hydrated** instances, not on freshly
42
+ constructed ones.
43
+ - Error mentions `__pydantic_extra__` or `__pydantic_private__` inside Pydantic
44
+ or serialization helpers.
45
+ - You recently added code that calls `dict(model)`, iterates the model, or runs
46
+ a framework that deep-visits objects.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.9.1"
3
+ version = "0.9.2"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -61,6 +61,35 @@ fn active_connection_for_route(using: Option<String>) -> PyResult<(String, Arc<E
61
61
  connection_for_route(using)
62
62
  }
63
63
 
64
+ /// Initialize Pydantic v2 slots that `BaseModel.__init__` normally sets, after zero-copy
65
+ /// hydration (`__new__` + `__dict__` population).
66
+ ///
67
+ /// These attributes live in `__slots__`; if never assigned, reads raise `AttributeError`,
68
+ /// which breaks `dict(model)`, iteration, `model_copy`, and libraries that recurse results
69
+ /// (for example Prefect's `visit_collection`).
70
+ ///
71
+ /// When `model_config["extra"] == "allow"`, `__pydantic_extra__` starts as an empty dict;
72
+ /// otherwise it is `None`. `__pydantic_private__` is always `None` for ORM-hydrated rows.
73
+ fn set_pydantic_hydration_slots<'py>(
74
+ py: Python<'py>,
75
+ cls: &Bound<'py, PyAny>,
76
+ instance: &Bound<'py, PyAny>,
77
+ ) -> PyResult<()> {
78
+ let model_config = cls.getattr(pyo3::intern!(py, "model_config"))?;
79
+ let extra_policy = model_config.call_method1(
80
+ pyo3::intern!(py, "get"),
81
+ (pyo3::intern!(py, "extra"), pyo3::intern!(py, "ignore")),
82
+ )?;
83
+ let extra_slot = if extra_policy.eq(pyo3::intern!(py, "allow"))? {
84
+ pyo3::types::PyDict::new(py).into_any().unbind()
85
+ } else {
86
+ py.None()
87
+ };
88
+ instance.setattr(pyo3::intern!(py, "__pydantic_extra__"), extra_slot)?;
89
+ instance.setattr(pyo3::intern!(py, "__pydantic_private__"), py.None())?;
90
+ Ok(())
91
+ }
92
+
64
93
  /// Map SeaQuery `Value` variants to `EngineBindValue`.
65
94
  ///
66
95
  /// Typed `None` variants are preserved as `Null(NullKind::T)` so the bind
@@ -1019,6 +1048,7 @@ pub fn fetch_all<'py>(
1019
1048
  }
1020
1049
 
1021
1050
  let _ = instance.setattr(pydantic_fields_set_str, fields_set);
1051
+ set_pydantic_hydration_slots(py, &cls, &instance)?;
1022
1052
 
1023
1053
  if use_identity_map {
1024
1054
  if let Some(pk_val) = row_pk_val {
@@ -1185,6 +1215,7 @@ pub fn fetch_one<'py>(
1185
1215
  }
1186
1216
 
1187
1217
  let _ = instance.setattr(pyo3::intern!(py, "__pydantic_fields_set__"), fields_set);
1218
+ set_pydantic_hydration_slots(py, &cls, &instance)?;
1188
1219
  if use_identity_map {
1189
1220
  IDENTITY_MAP.insert(
1190
1221
  (connection_name.clone(), name.clone(), pk_val),
@@ -1691,6 +1722,7 @@ pub fn fetch_filtered<'py>(
1691
1722
  }
1692
1723
 
1693
1724
  let _ = instance.setattr(pydantic_fields_set_str, fields_set);
1725
+ set_pydantic_hydration_slots(py, &cls, &instance)?;
1694
1726
 
1695
1727
  if use_identity_map {
1696
1728
  if let Some(pk_val) = row_pk_val {
@@ -0,0 +1,100 @@
1
+ import pytest
2
+ from pydantic import ConfigDict, Field
3
+
4
+ import ferro
5
+ from ferro import Model
6
+
7
+ pytestmark = pytest.mark.backend_matrix
8
+
9
+
10
+ INIT_CALLED_COUNT = 0
11
+
12
+
13
+ @pytest.mark.asyncio
14
+ async def test_direct_injection_bypasses_init(db_url):
15
+ """
16
+ Test that Ferro's Direct Injection bypasses the Python __init__ method.
17
+ """
18
+
19
+ # Use a unique class name for this test to avoid registry issues
20
+ class HydrationTestUser(Model):
21
+ id: int = Field(default=None, json_schema_extra={"primary_key": True})
22
+ name: str
23
+
24
+ def __init__(self, **data):
25
+ super().__init__(**data)
26
+ global INIT_CALLED_COUNT
27
+ INIT_CALLED_COUNT += 1
28
+
29
+ await ferro.connect(db_url, auto_migrate=True)
30
+
31
+ # 1. Create a record normally (this WILL call __init__)
32
+ global INIT_CALLED_COUNT
33
+ INIT_CALLED_COUNT = 0
34
+ user = HydrationTestUser(id=1, name="Direct Injector")
35
+ await user.save()
36
+ assert INIT_CALLED_COUNT == 1
37
+
38
+ # 2. Reset engine to clear Identity Map (so we force a DB fetch)
39
+ ferro.reset_engine()
40
+ await ferro.connect(db_url, auto_migrate=True)
41
+
42
+ # 3. Fetch the record
43
+ INIT_CALLED_COUNT = 0
44
+ fetched_user = await HydrationTestUser.get(1)
45
+
46
+ assert fetched_user is not None
47
+ assert fetched_user.name == "Direct Injector"
48
+
49
+ # CRITICAL ASSERTION: If Direct Injection is working, __init__ was never called
50
+ # by the Rust core when instantiating this object.
51
+ assert INIT_CALLED_COUNT == 0
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_hydrated_row_initializes_pydantic_slots(db_url):
56
+ """Rust-hydrated instances must match __init__ for Pydantic slot attributes."""
57
+
58
+ class SlotCheckUser(Model):
59
+ id: int = Field(default=None, json_schema_extra={"primary_key": True})
60
+ name: str
61
+
62
+ await ferro.connect(db_url, auto_migrate=True)
63
+ created = SlotCheckUser(id=1, name="slot-check")
64
+ await created.save()
65
+
66
+ ferro.reset_engine()
67
+ await ferro.connect(db_url, auto_migrate=True)
68
+
69
+ row = await SlotCheckUser.get(1)
70
+ assert row is not None
71
+ assert dict(row)["name"] == "slot-check"
72
+ assert row.__pydantic_extra__ is None
73
+ assert row.__pydantic_private__ is None
74
+ copied = row.model_copy()
75
+ assert copied.name == row.name
76
+ assert dict(copied)["name"] == "slot-check"
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_hydrated_extra_allow_starts_with_empty_extra_dict(db_url):
81
+ """When extra='allow', __pydantic_extra__ is {} even when no unknown keys exist."""
82
+
83
+ class ExtraAllowUser(Model):
84
+ model_config = ConfigDict(extra="allow")
85
+
86
+ id: int = Field(default=None, json_schema_extra={"primary_key": True})
87
+ name: str
88
+
89
+ await ferro.connect(db_url, auto_migrate=True)
90
+ created = ExtraAllowUser(id=1, name="ea")
91
+ await created.save()
92
+
93
+ ferro.reset_engine()
94
+ await ferro.connect(db_url, auto_migrate=True)
95
+
96
+ row = await ExtraAllowUser.get(1)
97
+ assert row is not None
98
+ assert row.__pydantic_extra__ == {}
99
+ assert row.__pydantic_private__ is None
100
+ assert dict(row)["name"] == "ea"
@@ -1,51 +0,0 @@
1
- import pytest
2
- from pydantic import Field
3
-
4
- import ferro
5
- from ferro import Model
6
-
7
- pytestmark = pytest.mark.backend_matrix
8
-
9
-
10
- INIT_CALLED_COUNT = 0
11
-
12
-
13
- @pytest.mark.asyncio
14
- async def test_direct_injection_bypasses_init(db_url):
15
- """
16
- Test that Ferro's Direct Injection bypasses the Python __init__ method.
17
- """
18
-
19
- # Use a unique class name for this test to avoid registry issues
20
- class HydrationTestUser(Model):
21
- id: int = Field(default=None, json_schema_extra={"primary_key": True})
22
- name: str
23
-
24
- def __init__(self, **data):
25
- super().__init__(**data)
26
- global INIT_CALLED_COUNT
27
- INIT_CALLED_COUNT += 1
28
-
29
- await ferro.connect(db_url, auto_migrate=True)
30
-
31
- # 1. Create a record normally (this WILL call __init__)
32
- global INIT_CALLED_COUNT
33
- INIT_CALLED_COUNT = 0
34
- user = HydrationTestUser(id=1, name="Direct Injector")
35
- await user.save()
36
- assert INIT_CALLED_COUNT == 1
37
-
38
- # 2. Reset engine to clear Identity Map (so we force a DB fetch)
39
- ferro.reset_engine()
40
- await ferro.connect(db_url, auto_migrate=True)
41
-
42
- # 3. Fetch the record
43
- INIT_CALLED_COUNT = 0
44
- fetched_user = await HydrationTestUser.get(1)
45
-
46
- assert fetched_user is not None
47
- assert fetched_user.name == "Direct Injector"
48
-
49
- # CRITICAL ASSERTION: If Direct Injection is working, __init__ was never called
50
- # by the Rust core when instantiating this object.
51
- assert INIT_CALLED_COUNT == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes