ferro-orm 0.9.0__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.
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/AGENTS.md +8 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/CHANGELOG.md +17 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/Cargo.lock +6 -6
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/Cargo.toml +1 -1
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/PKG-INFO +1 -1
- ferro_orm-0.9.2/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +46 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/pyproject.toml +1 -1
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/models.py +7 -1
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/operations.rs +32 -0
- ferro_orm-0.9.2/tests/test_hydration.py +100 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_named_connections_integration.py +4 -0
- ferro_orm-0.9.0/tests/test_hydration.py +0 -51
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.gitignore +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/.python-version +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/LICENSE +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/README.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/fields.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/model.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/query.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/relationships.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/transactions.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/api/utilities.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/changelog.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/coming-soon.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/contributing.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/faq.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/backend.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/database.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/queries.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/howto/testing.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/index.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/README.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/why-ferro.md +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/justfile +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/mkdocs.yml +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/backend.rs +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/connection.rs +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/base.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/fields.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/py.typed +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/raw.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/ferro/state.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/lib.rs +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/query.rs +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/schema.rs +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/src/state.rs +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/__init__.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/conftest.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/db_backends.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_connection.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_constraints.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_crud.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_deletion.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_helpers.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_metadata.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_models.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_refresh.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_schema.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_string_search.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_transactions.py +0 -0
- {ferro_orm-0.9.0 → ferro_orm-0.9.2}/tests/test_typed_null_binds.py +0 -0
- {ferro_orm-0.9.0 → 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,23 @@
|
|
|
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
|
+
|
|
13
|
+
## v0.9.1 (2026-05-11)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- ModelConnection annotations
|
|
18
|
+
([`337b983`](https://github.com/syn54x/ferro-orm/commit/337b9838c18835b6e00bc20487b9c24fc76bfaeb))
|
|
19
|
+
|
|
20
|
+
|
|
4
21
|
## v0.9.0 (2026-05-09)
|
|
5
22
|
|
|
6
23
|
### Chores
|
|
@@ -294,7 +294,7 @@ dependencies = [
|
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "ferro"
|
|
297
|
-
version = "0.9.
|
|
297
|
+
version = "0.9.2"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
|
@@ -498,9 +498,9 @@ dependencies = [
|
|
|
498
498
|
|
|
499
499
|
[[package]]
|
|
500
500
|
name = "hashbrown"
|
|
501
|
-
version = "0.17.
|
|
501
|
+
version = "0.17.1"
|
|
502
502
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
503
|
-
checksum = "
|
|
503
|
+
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
504
504
|
|
|
505
505
|
[[package]]
|
|
506
506
|
name = "hashlink"
|
|
@@ -678,7 +678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
|
678
678
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
|
679
679
|
dependencies = [
|
|
680
680
|
"equivalent",
|
|
681
|
-
"hashbrown 0.17.
|
|
681
|
+
"hashbrown 0.17.1",
|
|
682
682
|
"serde",
|
|
683
683
|
"serde_core",
|
|
684
684
|
]
|
|
@@ -2296,9 +2296,9 @@ dependencies = [
|
|
|
2296
2296
|
|
|
2297
2297
|
[[package]]
|
|
2298
2298
|
name = "zerofrom"
|
|
2299
|
-
version = "0.1.
|
|
2299
|
+
version = "0.1.8"
|
|
2300
2300
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2301
|
-
checksum = "
|
|
2301
|
+
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
|
2302
2302
|
dependencies = [
|
|
2303
2303
|
"zerofrom-derive",
|
|
2304
2304
|
]
|
|
@@ -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.
|
|
@@ -616,7 +616,13 @@ class ModelConnection[M: Model]:
|
|
|
616
616
|
def select(self) -> Query[M]:
|
|
617
617
|
return Query(self.model_cls, using=self._connection_name)
|
|
618
618
|
|
|
619
|
-
|
|
619
|
+
@overload
|
|
620
|
+
def where(self, node: QueryNode) -> Query[M]: ...
|
|
621
|
+
|
|
622
|
+
@overload
|
|
623
|
+
def where(self, node: "Predicate[M]") -> Query[M]: ...
|
|
624
|
+
|
|
625
|
+
def where(self, node: "QueryNode | Predicate[M]") -> Query[M]:
|
|
620
626
|
return self.select().where(node)
|
|
621
627
|
|
|
622
628
|
async def get(self, pk: Any) -> M:
|
|
@@ -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"
|
|
@@ -35,6 +35,10 @@ if TYPE_CHECKING:
|
|
|
35
35
|
bound.where(NamedSmokeMarker.id == 1), # type: ignore[arg-type]
|
|
36
36
|
"Query[NamedSmokeMarker]",
|
|
37
37
|
)
|
|
38
|
+
assert_type(
|
|
39
|
+
bound.where(lambda t: t.id == 1), # type: ignore[arg-type]
|
|
40
|
+
"Query[NamedSmokeMarker]",
|
|
41
|
+
)
|
|
38
42
|
assert_type(await bound.get(1), NamedSmokeMarker)
|
|
39
43
|
assert_type(await bound.get_or_none(1), NamedSmokeMarker | None)
|
|
40
44
|
assert_type(await bound.bulk_create([]), int)
|
|
@@ -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
|
|
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
|
{ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md
RENAMED
|
File without changes
|
|
File without changes
|
{ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md
RENAMED
|
File without changes
|
{ferro_orm-0.9.0 → ferro_orm-0.9.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md
RENAMED
|
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
|
|
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
|
|
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
|