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.
Files changed (157) hide show
  1. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/CHANGELOG.md +8 -0
  2. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/Cargo.lock +5 -5
  3. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/Cargo.toml +1 -1
  4. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/PKG-INFO +1 -1
  5. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/pyproject.toml +1 -1
  6. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/backend.rs +17 -0
  7. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/connection.rs +4 -2
  8. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/__init__.py +6 -0
  9. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/_core.pyi +2 -0
  10. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/operations.rs +67 -38
  11. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_type_mapping.py +21 -0
  12. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_connection.py +1 -0
  13. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_crud.py +21 -0
  14. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_structural_types.py +42 -0
  15. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -0
  16. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -0
  17. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -0
  18. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -0
  19. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -0
  20. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -0
  21. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -0
  22. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -0
  23. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/PERMISSIONS.md +0 -0
  26. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/PYPI_CHECKLIST.md +0 -0
  27. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/PYPI_SETUP.md +0 -0
  28. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/generated/wheels.generated.yml +0 -0
  29. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/pull_request_template.md +0 -0
  30. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/ci.yml +0 -0
  31. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/packaging-smoke.yml +0 -0
  32. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/publish-docs.yml +0 -0
  33. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/publish.yml +0 -0
  34. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.github/workflows/release.yml +0 -0
  35. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.gitignore +0 -0
  36. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.pre-commit-config.yaml +0 -0
  37. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/.python-version +0 -0
  38. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/AGENTS.md +0 -0
  39. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/CONTRIBUTING.md +0 -0
  40. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/LICENSE +0 -0
  41. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/README.md +0 -0
  42. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/TEST_RESULTS.md +0 -0
  43. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/fields.md +0 -0
  44. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/model.md +0 -0
  45. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/query.md +0 -0
  46. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/raw-sql.md +0 -0
  47. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/relationships.md +0 -0
  48. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/transactions.md +0 -0
  49. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/api/utilities.md +0 -0
  50. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  51. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/changelog.md +0 -0
  52. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/coming-soon.md +0 -0
  53. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/architecture.md +0 -0
  54. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/identity-map.md +0 -0
  55. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/performance.md +0 -0
  56. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/concepts/type-safety.md +0 -0
  57. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/contributing.md +0 -0
  58. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/faq.md +0 -0
  59. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/getting-started/installation.md +0 -0
  60. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/getting-started/next-steps.md +0 -0
  61. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/getting-started/tutorial.md +0 -0
  62. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/backend.md +0 -0
  63. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/database.md +0 -0
  64. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/migrations.md +0 -0
  65. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/models-and-fields.md +0 -0
  66. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/mutations.md +0 -0
  67. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/queries.md +0 -0
  68. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/relationships.md +0 -0
  69. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/guide/transactions.md +0 -0
  70. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/multiple-databases.md +0 -0
  71. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/pagination.md +0 -0
  72. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/soft-deletes.md +0 -0
  73. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/testing.md +0 -0
  74. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/howto/timestamps.md +0 -0
  75. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/index.md +0 -0
  76. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/migration-sqlalchemy.md +0 -0
  77. {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
  78. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  79. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  80. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  81. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/README.md +0 -0
  82. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  83. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  84. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  85. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  86. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  87. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  88. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  89. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/solutions/patterns/typed-null-binds.md +0 -0
  90. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/stylesheets/extra.css +0 -0
  91. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/docs/why-ferro.md +0 -0
  92. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/justfile +0 -0
  93. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/mkdocs.yml +0 -0
  94. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/scripts/demo_queries.py +0 -0
  95. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/_annotation_utils.py +0 -0
  96. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/_shadow_fk_types.py +0 -0
  97. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/base.py +0 -0
  98. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/composite_indexes.py +0 -0
  99. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/composite_uniques.py +0 -0
  100. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/fields.py +0 -0
  101. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/metaclass.py +0 -0
  102. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/migrations/__init__.py +0 -0
  103. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/migrations/alembic.py +0 -0
  104. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/models.py +0 -0
  105. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/py.typed +0 -0
  106. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/query/__init__.py +0 -0
  107. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/query/builder.py +0 -0
  108. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/query/nodes.py +0 -0
  109. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/raw.py +0 -0
  110. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/relations/__init__.py +0 -0
  111. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/relations/descriptors.py +0 -0
  112. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/schema_metadata.py +0 -0
  113. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/ferro/state.py +0 -0
  114. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/lib.rs +0 -0
  115. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/query.rs +0 -0
  116. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/schema.rs +0 -0
  117. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/src/state.rs +0 -0
  118. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/__init__.py +0 -0
  119. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/conftest.py +0 -0
  120. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/db_backends.py +0 -0
  121. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_aggregation.py +0 -0
  122. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_autogenerate.py +0 -0
  123. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_bridge.py +0 -0
  124. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_alembic_nullability.py +0 -0
  125. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_auto_migrate.py +0 -0
  126. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_bulk_update.py +0 -0
  127. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_composite_index.py +0 -0
  128. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_composite_unique.py +0 -0
  129. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_connection_redaction.py +0 -0
  130. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_constraints.py +0 -0
  131. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_cross_emitter_parity.py +0 -0
  132. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_db_backends.py +0 -0
  133. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_deletion.py +0 -0
  134. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_docs_examples.py +0 -0
  135. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_documentation_features.py +0 -0
  136. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_field_wrapper.py +0 -0
  137. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_helpers.py +0 -0
  138. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_hydration.py +0 -0
  139. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_metaclass_internals.py +0 -0
  140. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_metadata.py +0 -0
  141. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_models.py +0 -0
  142. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_named_connections_integration.py +0 -0
  143. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_one_to_one.py +0 -0
  144. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_query_builder.py +0 -0
  145. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_raw_sql.py +0 -0
  146. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_refresh.py +0 -0
  147. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_relationship_engine.py +0 -0
  148. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_schema.py +0 -0
  149. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_schema_constraints.py +0 -0
  150. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_schema_enum_annotations.py +0 -0
  151. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_shadow_fk_types.py +0 -0
  152. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_static_contracts.py +0 -0
  153. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_string_search.py +0 -0
  154. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_temporal_types.py +0 -0
  155. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_transactions.py +0 -0
  156. {ferro_orm-0.6.1 → ferro_orm-0.7.0}/tests/test_typed_null_binds.py +0 -0
  157. {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.61"
82
+ version = "1.2.62"
83
83
  source = "registry+https://github.com/rust-lang/crates.io-index"
84
- checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
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.6.1"
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.2"
1702
+ version = "1.52.3"
1703
1703
  source = "registry+https://github.com/rust-lang/crates.io-index"
1704
- checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
1704
+ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
1705
1705
  dependencies = [
1706
1706
  "bytes",
1707
1707
  "libc",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.6.1"
3
+ version = "0.7.0"
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.6.1
3
+ Version: 0.7.0
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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
 
@@ -8,6 +8,8 @@ async def connect(
8
8
  default: bool = False,
9
9
  max_connections: int = 5,
10
10
  min_connections: int = 0,
11
+ *,
12
+ identity_map: bool = True,
11
13
  ) -> None: ...
12
14
  async def create_tables(using: Optional[str] = None) -> None: ...
13
15
  async def fetch_all(
@@ -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 let Some(ref pk_val) = row_pk_val
995
- && let Some(existing_obj) =
996
- IDENTITY_MAP.get(&(connection_name.clone(), name.clone(), pk_val.clone()))
997
- {
998
- results.append(existing_obj.value().clone_ref(py))?;
999
- continue;
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 let Some(pk_val) = row_pk_val {
1018
- IDENTITY_MAP.insert(
1019
- (connection_name.clone(), name.clone(), pk_val),
1020
- instance.clone().unbind(),
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, _) = active_connection_for_route(using.clone())?;
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 let Some(existing_obj) =
1060
- IDENTITY_MAP.get(&(connection_name.clone(), name.clone(), pk_val.clone()))
1061
- {
1062
- let obj = existing_obj.value().clone_ref(py);
1063
- return pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(obj) });
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
- IDENTITY_MAP.insert(
1178
- (connection_name.clone(), name.clone(), pk_val),
1179
- instance.clone().unbind(),
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 let Some(ref pk_val) = row_pk_val
1654
- && let Some(existing_obj) =
1655
- IDENTITY_MAP.get(&(connection_name.clone(), name.clone(), pk_val.clone()))
1656
- {
1657
- results.append(existing_obj.value().clone_ref(py))?;
1658
- continue;
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 let Some(pk_val) = row_pk_val {
1677
- IDENTITY_MAP.insert(
1678
- (connection_name.clone(), name.clone(), pk_val),
1679
- instance.clone().unbind(),
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, _) = active_connection_for_route(using)?;
1806
- IDENTITY_MAP.insert((connection_name, name, pk), obj);
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, _) = active_connection_for_route(using)?;
1815
- IDENTITY_MAP.remove(&(connection_name, name, pk));
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
- IDENTITY_MAP.retain(|(_, m_name, _), _| m_name != &name);
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
- IDENTITY_MAP.retain(|(_, m_name, _), _| m_name != &name);
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
 
@@ -479,6 +479,7 @@ async def test_connect_passes_pool_config_to_core(monkeypatch):
479
479
  "default": False,
480
480
  "max_connections": 3,
481
481
  "min_connections": 1,
482
+ "identity_map": True,
482
483
  },
483
484
  )
484
485
  ]
@@ -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