ferro-orm 0.6.1__tar.gz → 0.7.0__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.6.1 → ferro_orm-0.7.0}/CHANGELOG.md +8 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/Cargo.lock +5 -5
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/Cargo.toml +1 -1
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/PKG-INFO +1 -1
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/pyproject.toml +1 -1
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/backend.rs +17 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/connection.rs +4 -2
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/__init__.py +6 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/_core.pyi +2 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/operations.rs +67 -38
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_type_mapping.py +21 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_connection.py +1 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_crud.py +21 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_structural_types.py +42 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.gitignore +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.python-version +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/AGENTS.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/LICENSE +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/README.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/fields.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/model.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/query.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/relationships.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/transactions.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/utilities.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/changelog.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/coming-soon.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/contributing.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/faq.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/backend.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/database.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/queries.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/testing.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/index.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/README.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/why-ferro.md +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/justfile +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/mkdocs.yml +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/base.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/fields.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/models.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/raw.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/state.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/lib.rs +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/query.rs +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/schema.rs +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/state.rs +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/__init__.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/conftest.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_deletion.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_models.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_transactions.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_typed_null_binds.py +0 -0
- {ferro_orm-0.6.1 → ferro_orm-0.7.0}/uv.lock +0 -0
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.7.0 (2026-05-08)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- Per-connection identity_map on connect ([#47](https://github.com/syn54x/ferro-orm/pull/47),
|
|
9
|
+
[`0a1d629`](https://github.com/syn54x/ferro-orm/commit/0a1d62926538cde14fdd4f4deece21a59a1ede69))
|
|
10
|
+
|
|
11
|
+
|
|
4
12
|
## v0.6.1 (2026-05-07)
|
|
5
13
|
|
|
6
14
|
### Refactoring
|
|
@@ -79,9 +79,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|
|
79
79
|
|
|
80
80
|
[[package]]
|
|
81
81
|
name = "cc"
|
|
82
|
-
version = "1.2.
|
|
82
|
+
version = "1.2.62"
|
|
83
83
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
84
|
-
checksum = "
|
|
84
|
+
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
|
85
85
|
dependencies = [
|
|
86
86
|
"find-msvc-tools",
|
|
87
87
|
"shlex",
|
|
@@ -294,7 +294,7 @@ dependencies = [
|
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "ferro"
|
|
297
|
-
version = "0.
|
|
297
|
+
version = "0.7.0"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
|
@@ -1699,9 +1699,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|
|
1699
1699
|
|
|
1700
1700
|
[[package]]
|
|
1701
1701
|
name = "tokio"
|
|
1702
|
-
version = "1.52.
|
|
1702
|
+
version = "1.52.3"
|
|
1703
1703
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1704
|
-
checksum = "
|
|
1704
|
+
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
|
1705
1705
|
dependencies = [
|
|
1706
1706
|
"bytes",
|
|
1707
1707
|
"libc",
|
|
@@ -30,6 +30,8 @@ impl BackendKind {
|
|
|
30
30
|
pub struct EngineHandle {
|
|
31
31
|
backend: BackendKind,
|
|
32
32
|
pool: BackendPool,
|
|
33
|
+
/// When false, Ferro skips the identity map for this connection (no lookup/register on load).
|
|
34
|
+
identity_map_enabled: bool,
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
#[derive(Clone, Debug)]
|
|
@@ -116,6 +118,7 @@ impl EngineHandle {
|
|
|
116
118
|
Self {
|
|
117
119
|
backend: BackendKind::Sqlite,
|
|
118
120
|
pool: BackendPool::Sqlite(Arc::new(pool)),
|
|
121
|
+
identity_map_enabled: true,
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -123,9 +126,23 @@ impl EngineHandle {
|
|
|
123
126
|
Self {
|
|
124
127
|
backend: BackendKind::Postgres,
|
|
125
128
|
pool: BackendPool::Postgres(Arc::new(pool)),
|
|
129
|
+
identity_map_enabled: true,
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/// Returns whether this connection uses the identity map (singleton instances per PK).
|
|
134
|
+
#[must_use]
|
|
135
|
+
pub fn is_identity_map_enabled(&self) -> bool {
|
|
136
|
+
self.identity_map_enabled
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Sets identity-map behavior for this handle (used by `connect(identity_map=...)`).
|
|
140
|
+
#[must_use]
|
|
141
|
+
pub fn with_identity_map_enabled(mut self, enabled: bool) -> Self {
|
|
142
|
+
self.identity_map_enabled = enabled;
|
|
143
|
+
self
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
pub fn backend(&self) -> BackendKind {
|
|
130
147
|
self.backend
|
|
131
148
|
}
|
|
@@ -187,7 +187,7 @@ async fn connect_engine_handle(
|
|
|
187
187
|
/// # Errors
|
|
188
188
|
/// Returns a `PyErr` if the connection fails or if auto-migration fails.
|
|
189
189
|
#[pyfunction]
|
|
190
|
-
#[pyo3(signature = (url, auto_migrate=false, name=None, default=false, max_connections=5, min_connections=0))]
|
|
190
|
+
#[pyo3(signature = (url, auto_migrate=false, name=None, default=false, max_connections=5, min_connections=0, identity_map=true))]
|
|
191
191
|
pub fn connect(
|
|
192
192
|
py: Python<'_>,
|
|
193
193
|
url: String,
|
|
@@ -196,6 +196,7 @@ pub fn connect(
|
|
|
196
196
|
default: bool,
|
|
197
197
|
max_connections: u32,
|
|
198
198
|
min_connections: u32,
|
|
199
|
+
identity_map: bool,
|
|
199
200
|
) -> PyResult<Bound<'_, PyAny>> {
|
|
200
201
|
let (connection_url, search_path) = split_search_path(&url);
|
|
201
202
|
let redacted_url = redact_connection_url(&connection_url);
|
|
@@ -247,7 +248,8 @@ pub fn connect(
|
|
|
247
248
|
"DB Connection failed for {}: {}",
|
|
248
249
|
redacted_url, e
|
|
249
250
|
))
|
|
250
|
-
})
|
|
251
|
+
})?
|
|
252
|
+
.with_identity_map_enabled(identity_map);
|
|
251
253
|
|
|
252
254
|
let engine_handle = Arc::new(engine_handle);
|
|
253
255
|
|
|
@@ -60,6 +60,8 @@ async def connect(
|
|
|
60
60
|
name: str | None = None,
|
|
61
61
|
default: bool = False,
|
|
62
62
|
pool: PoolConfig | None = None,
|
|
63
|
+
*,
|
|
64
|
+
identity_map: bool = True,
|
|
63
65
|
) -> None:
|
|
64
66
|
"""
|
|
65
67
|
Establish a connection to the database.
|
|
@@ -70,6 +72,9 @@ async def connect(
|
|
|
70
72
|
name: Optional connection name. Omitted connections register as "default".
|
|
71
73
|
default: If True, make this named connection the default for unqualified operations.
|
|
72
74
|
pool: Optional per-connection pool configuration.
|
|
75
|
+
identity_map: If True (default), keep a per-connection identity map so the same primary
|
|
76
|
+
key maps to a single Python instance. If False, each load returns fresh instances and
|
|
77
|
+
the map is not consulted (lower memory use; no ``a is b`` guarantees across loads).
|
|
73
78
|
"""
|
|
74
79
|
from .relations import resolve_relationships
|
|
75
80
|
|
|
@@ -83,6 +88,7 @@ async def connect(
|
|
|
83
88
|
default=default,
|
|
84
89
|
max_connections=pool_config.max_connections,
|
|
85
90
|
min_connections=pool_config.min_connections,
|
|
91
|
+
identity_map=identity_map,
|
|
86
92
|
)
|
|
87
93
|
|
|
88
94
|
|
|
@@ -910,6 +910,7 @@ pub fn fetch_all<'py>(
|
|
|
910
910
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
911
911
|
let (connection_name, engine, tx_conn, backend) =
|
|
912
912
|
active_route_for_operation(tx_id, using)?;
|
|
913
|
+
let use_identity_map = engine.is_identity_map_enabled();
|
|
913
914
|
|
|
914
915
|
let table_name = name.to_lowercase();
|
|
915
916
|
let pg_native_enum_cols: HashSet<String> = {
|
|
@@ -991,12 +992,17 @@ pub fn fetch_all<'py>(
|
|
|
991
992
|
let connection_attr_str = pyo3::intern!(py, "__ferro_connection_name");
|
|
992
993
|
|
|
993
994
|
for (row_pk_val, fields) in parsed_data {
|
|
994
|
-
if
|
|
995
|
-
|
|
996
|
-
IDENTITY_MAP.get(&(
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
995
|
+
if use_identity_map {
|
|
996
|
+
if let Some(ref pk_val) = row_pk_val
|
|
997
|
+
&& let Some(existing_obj) = IDENTITY_MAP.get(&(
|
|
998
|
+
connection_name.clone(),
|
|
999
|
+
name.clone(),
|
|
1000
|
+
pk_val.clone(),
|
|
1001
|
+
))
|
|
1002
|
+
{
|
|
1003
|
+
results.append(existing_obj.value().clone_ref(py))?;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1000
1006
|
}
|
|
1001
1007
|
|
|
1002
1008
|
let instance = cls.call_method1(new_str, (cls,))?;
|
|
@@ -1014,11 +1020,13 @@ pub fn fetch_all<'py>(
|
|
|
1014
1020
|
|
|
1015
1021
|
let _ = instance.setattr(pydantic_fields_set_str, fields_set);
|
|
1016
1022
|
|
|
1017
|
-
if
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1023
|
+
if use_identity_map {
|
|
1024
|
+
if let Some(pk_val) = row_pk_val {
|
|
1025
|
+
IDENTITY_MAP.insert(
|
|
1026
|
+
(connection_name.clone(), name.clone(), pk_val),
|
|
1027
|
+
instance.clone().unbind(),
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1022
1030
|
}
|
|
1023
1031
|
|
|
1024
1032
|
results.append(instance)?;
|
|
@@ -1053,19 +1061,22 @@ pub fn fetch_one<'py>(
|
|
|
1053
1061
|
) -> PyResult<Bound<'py, PyAny>> {
|
|
1054
1062
|
let name = cls.getattr("__name__")?.extract::<String>()?;
|
|
1055
1063
|
let cls_py = cls.unbind();
|
|
1056
|
-
let (connection_name,
|
|
1064
|
+
let (connection_name, engine) = active_connection_for_route(using.clone())?;
|
|
1057
1065
|
|
|
1058
1066
|
// Check Identity Map first (if no transaction, or even with transaction, IM is usually safe)
|
|
1059
|
-
if
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1067
|
+
if engine.is_identity_map_enabled() {
|
|
1068
|
+
if let Some(existing_obj) =
|
|
1069
|
+
IDENTITY_MAP.get(&(connection_name.clone(), name.clone(), pk_val.clone()))
|
|
1070
|
+
{
|
|
1071
|
+
let obj = existing_obj.value().clone_ref(py);
|
|
1072
|
+
return pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(obj) });
|
|
1073
|
+
}
|
|
1064
1074
|
}
|
|
1065
1075
|
|
|
1066
1076
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1067
1077
|
let (connection_name, engine, tx_conn, backend) =
|
|
1068
1078
|
active_route_for_operation(tx_id, using)?;
|
|
1079
|
+
let use_identity_map = engine.is_identity_map_enabled();
|
|
1069
1080
|
|
|
1070
1081
|
let table_name = name.to_lowercase();
|
|
1071
1082
|
let pg_native_enum_cols: HashSet<String> = {
|
|
@@ -1174,10 +1185,12 @@ pub fn fetch_one<'py>(
|
|
|
1174
1185
|
}
|
|
1175
1186
|
|
|
1176
1187
|
let _ = instance.setattr(pyo3::intern!(py, "__pydantic_fields_set__"), fields_set);
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1188
|
+
if use_identity_map {
|
|
1189
|
+
IDENTITY_MAP.insert(
|
|
1190
|
+
(connection_name.clone(), name.clone(), pk_val),
|
|
1191
|
+
instance.clone().unbind(),
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1181
1194
|
Ok(instance.into_any().unbind())
|
|
1182
1195
|
}),
|
|
1183
1196
|
None => Python::attach(|py| Ok(py.None())),
|
|
@@ -1523,6 +1536,7 @@ pub fn fetch_filtered<'py>(
|
|
|
1523
1536
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1524
1537
|
let (connection_name, engine, tx_conn, backend) =
|
|
1525
1538
|
active_route_for_operation(tx_id, using)?;
|
|
1539
|
+
let use_identity_map = engine.is_identity_map_enabled();
|
|
1526
1540
|
|
|
1527
1541
|
let table_name = name.to_lowercase();
|
|
1528
1542
|
let pg_native_enum_cols: HashSet<String> = {
|
|
@@ -1650,12 +1664,17 @@ pub fn fetch_filtered<'py>(
|
|
|
1650
1664
|
let connection_attr_str = pyo3::intern!(py, "__ferro_connection_name");
|
|
1651
1665
|
|
|
1652
1666
|
for (row_pk_val, fields) in parsed_data {
|
|
1653
|
-
if
|
|
1654
|
-
|
|
1655
|
-
IDENTITY_MAP.get(&(
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1667
|
+
if use_identity_map {
|
|
1668
|
+
if let Some(ref pk_val) = row_pk_val
|
|
1669
|
+
&& let Some(existing_obj) = IDENTITY_MAP.get(&(
|
|
1670
|
+
connection_name.clone(),
|
|
1671
|
+
name.clone(),
|
|
1672
|
+
pk_val.clone(),
|
|
1673
|
+
))
|
|
1674
|
+
{
|
|
1675
|
+
results.append(existing_obj.value().clone_ref(py))?;
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1659
1678
|
}
|
|
1660
1679
|
|
|
1661
1680
|
let instance = cls.call_method1(new_str, (cls,))?;
|
|
@@ -1673,11 +1692,13 @@ pub fn fetch_filtered<'py>(
|
|
|
1673
1692
|
|
|
1674
1693
|
let _ = instance.setattr(pydantic_fields_set_str, fields_set);
|
|
1675
1694
|
|
|
1676
|
-
if
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1695
|
+
if use_identity_map {
|
|
1696
|
+
if let Some(pk_val) = row_pk_val {
|
|
1697
|
+
IDENTITY_MAP.insert(
|
|
1698
|
+
(connection_name.clone(), name.clone(), pk_val),
|
|
1699
|
+
instance.clone().unbind(),
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1681
1702
|
}
|
|
1682
1703
|
|
|
1683
1704
|
results.append(instance)?;
|
|
@@ -1802,8 +1823,10 @@ pub fn register_instance(
|
|
|
1802
1823
|
obj: Py<PyAny>,
|
|
1803
1824
|
using: Option<String>,
|
|
1804
1825
|
) -> PyResult<()> {
|
|
1805
|
-
let (connection_name,
|
|
1806
|
-
|
|
1826
|
+
let (connection_name, engine) = active_connection_for_route(using)?;
|
|
1827
|
+
if engine.is_identity_map_enabled() {
|
|
1828
|
+
IDENTITY_MAP.insert((connection_name, name, pk), obj);
|
|
1829
|
+
}
|
|
1807
1830
|
Ok(())
|
|
1808
1831
|
}
|
|
1809
1832
|
|
|
@@ -1811,8 +1834,10 @@ pub fn register_instance(
|
|
|
1811
1834
|
#[pyfunction]
|
|
1812
1835
|
#[pyo3(signature = (name, pk, using=None))]
|
|
1813
1836
|
pub fn evict_instance(name: String, pk: String, using: Option<String>) -> PyResult<()> {
|
|
1814
|
-
let (connection_name,
|
|
1815
|
-
|
|
1837
|
+
let (connection_name, engine) = active_connection_for_route(using)?;
|
|
1838
|
+
if engine.is_identity_map_enabled() {
|
|
1839
|
+
IDENTITY_MAP.remove(&(connection_name, name, pk));
|
|
1840
|
+
}
|
|
1816
1841
|
Ok(())
|
|
1817
1842
|
}
|
|
1818
1843
|
|
|
@@ -1920,7 +1945,9 @@ pub fn delete_filtered(
|
|
|
1920
1945
|
})?;
|
|
1921
1946
|
|
|
1922
1947
|
// After bulk delete, we MUST clear the Identity Map for this model to avoid stale objects
|
|
1923
|
-
|
|
1948
|
+
if engine.is_identity_map_enabled() {
|
|
1949
|
+
IDENTITY_MAP.retain(|(_, m_name, _), _| m_name != &name);
|
|
1950
|
+
}
|
|
1924
1951
|
|
|
1925
1952
|
Ok(rows_affected)
|
|
1926
1953
|
})
|
|
@@ -1996,7 +2023,9 @@ pub fn update_filtered(
|
|
|
1996
2023
|
})?;
|
|
1997
2024
|
|
|
1998
2025
|
// After bulk update, we MUST clear the Identity Map for this model to avoid stale objects
|
|
1999
|
-
|
|
2026
|
+
if engine.is_identity_map_enabled() {
|
|
2027
|
+
IDENTITY_MAP.retain(|(_, m_name, _), _| m_name != &name);
|
|
2028
|
+
}
|
|
2000
2029
|
|
|
2001
2030
|
Ok(rows_affected)
|
|
2002
2031
|
})
|
|
@@ -5,6 +5,7 @@ from uuid import UUID
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
import sqlalchemy as sa
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
8
9
|
|
|
9
10
|
from ferro import FerroField, Model, clear_registry, reset_engine
|
|
10
11
|
from ferro.migrations import get_metadata
|
|
@@ -58,6 +59,26 @@ def test_complex_type_mapping():
|
|
|
58
59
|
assert isinstance(table.c.tags.type, sa.JSON)
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def test_list_of_nested_pydantic_models_maps_to_json_column():
|
|
63
|
+
"""list[BaseModel] should emit an array JSON Schema and map to sa.JSON like other JSON columns."""
|
|
64
|
+
|
|
65
|
+
class JsonListPart(BaseModel):
|
|
66
|
+
name: str
|
|
67
|
+
count: int
|
|
68
|
+
|
|
69
|
+
class ModelWithNestedListJson(Model):
|
|
70
|
+
id: Annotated[int, FerroField(primary_key=True)]
|
|
71
|
+
parts: list[JsonListPart] = Field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
meta = get_metadata()
|
|
74
|
+
table = meta.tables["modelwithnestedlistjson"]
|
|
75
|
+
assert isinstance(table.c.parts.type, sa.JSON)
|
|
76
|
+
|
|
77
|
+
props = ModelWithNestedListJson.__ferro_schema__["properties"]["parts"]
|
|
78
|
+
assert props.get("type") == "array"
|
|
79
|
+
assert props.get("items", {}).get("$ref") == "#/$defs/JsonListPart"
|
|
80
|
+
|
|
81
|
+
|
|
61
82
|
def test_optional_complex_types():
|
|
62
83
|
"""Verify that Optional complex types are still mapped correctly."""
|
|
63
84
|
|
|
@@ -101,6 +101,27 @@ async def test_identity_map_consistency(db_url):
|
|
|
101
101
|
assert user_a.id == 100
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_identity_map_disabled_returns_distinct_instances(db_url):
|
|
106
|
+
"""With identity_map=False, repeated loads are distinct objects (same field values)."""
|
|
107
|
+
|
|
108
|
+
class CrudUser(Model):
|
|
109
|
+
id: int = Field(default=None, json_schema_extra={"primary_key": True})
|
|
110
|
+
username: str
|
|
111
|
+
email: str
|
|
112
|
+
|
|
113
|
+
await ferro.connect(db_url, auto_migrate=True, identity_map=False)
|
|
114
|
+
u1 = CrudUser(id=200, username="nomap", email="nomap@test.com")
|
|
115
|
+
await u1.save()
|
|
116
|
+
results_1 = await CrudUser.all()
|
|
117
|
+
results_2 = await CrudUser.all()
|
|
118
|
+
user_a = results_1[0]
|
|
119
|
+
user_b = results_2[0]
|
|
120
|
+
assert user_a is not user_b
|
|
121
|
+
assert user_a.id == user_b.id == 200
|
|
122
|
+
assert user_a.username == user_b.username
|
|
123
|
+
|
|
124
|
+
|
|
104
125
|
@pytest.mark.asyncio
|
|
105
126
|
async def test_model_get_operation(db_url):
|
|
106
127
|
"""Test fetching a single record by primary key."""
|
|
@@ -3,6 +3,9 @@ import uuid
|
|
|
3
3
|
from decimal import Decimal
|
|
4
4
|
from enum import Enum
|
|
5
5
|
from typing import Annotated, Dict, List
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
6
9
|
from ferro import Model, connect, FerroField
|
|
7
10
|
|
|
8
11
|
pytestmark = pytest.mark.backend_matrix
|
|
@@ -78,6 +81,45 @@ async def test_structural_types_roundtrip(db_url):
|
|
|
78
81
|
assert fetched.balance == balance
|
|
79
82
|
|
|
80
83
|
|
|
84
|
+
@pytest.mark.asyncio
|
|
85
|
+
async def test_json_column_list_of_nested_pydantic_models_roundtrip(db_url):
|
|
86
|
+
"""list[BaseModel] stores as JSON; accepts models on write; reload yields dicts."""
|
|
87
|
+
|
|
88
|
+
class JsonListItem(BaseModel):
|
|
89
|
+
field_a: str
|
|
90
|
+
field_b: int
|
|
91
|
+
|
|
92
|
+
class JsonListParent(Model):
|
|
93
|
+
id: Annotated[int | None, FerroField(primary_key=True)] = None
|
|
94
|
+
items: list[JsonListItem] = Field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
await connect(db_url, auto_migrate=True)
|
|
97
|
+
|
|
98
|
+
empty = await JsonListParent.create(items=[])
|
|
99
|
+
from ferro import evict_instance
|
|
100
|
+
|
|
101
|
+
evict_instance("JsonListParent", str(empty.id))
|
|
102
|
+
fetched_empty = await JsonListParent.get(empty.id)
|
|
103
|
+
assert fetched_empty is not None
|
|
104
|
+
assert fetched_empty.items == []
|
|
105
|
+
|
|
106
|
+
payload = [
|
|
107
|
+
JsonListItem(field_a="x", field_b=1),
|
|
108
|
+
JsonListItem(field_a="y", field_b=2),
|
|
109
|
+
]
|
|
110
|
+
row = await JsonListParent.create(items=payload)
|
|
111
|
+
assert all(isinstance(it, JsonListItem) for it in row.items)
|
|
112
|
+
evict_instance("JsonListParent", str(row.id))
|
|
113
|
+
fetched = await JsonListParent.get(row.id)
|
|
114
|
+
assert fetched is not None
|
|
115
|
+
assert isinstance(fetched.items, list)
|
|
116
|
+
assert len(fetched.items) == 2
|
|
117
|
+
assert all(isinstance(it, dict) for it in fetched.items)
|
|
118
|
+
assert fetched.items == [p.model_dump() for p in payload]
|
|
119
|
+
revived = [JsonListItem.model_validate(it) for it in fetched.items]
|
|
120
|
+
assert revived == payload
|
|
121
|
+
|
|
122
|
+
|
|
81
123
|
@pytest.mark.asyncio
|
|
82
124
|
async def test_structural_filtering(db_url):
|
|
83
125
|
"""Test filtering by UUID and Decimal."""
|
|
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
|
{ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md
RENAMED
|
File without changes
|
{ferro_orm-0.6.1 → ferro_orm-0.7.0}/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
|