ferro-orm 0.12.2__tar.gz → 0.12.3__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 (227) hide show
  1. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/CHANGELOG.md +13 -0
  2. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/Cargo.lock +1 -1
  3. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/Cargo.toml +1 -1
  4. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/PKG-INFO +1 -1
  5. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/connections.md +2 -0
  6. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/transactions.md +2 -0
  7. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/pyproject.toml +1 -1
  8. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/session.py +22 -16
  9. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/operations.rs +3 -2
  10. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/state.rs +64 -0
  11. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_session.py +168 -0
  12. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  13. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  14. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/PERMISSIONS.md +0 -0
  15. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/PYPI_CHECKLIST.md +0 -0
  16. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/PYPI_SETUP.md +0 -0
  17. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/generated/wheels.generated.yml +0 -0
  18. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/pull_request_template.md +0 -0
  19. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/workflows/ci.yml +0 -0
  20. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/workflows/packaging-smoke.yml +0 -0
  21. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/workflows/publish-docs.yml +0 -0
  22. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/workflows/publish.yml +0 -0
  23. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.github/workflows/release.yml +0 -0
  24. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.gitignore +0 -0
  25. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.pre-commit-config.yaml +0 -0
  26. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/.python-version +0 -0
  27. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/AGENTS.md +0 -0
  28. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/CONTRIBUTING.md +0 -0
  29. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/LICENSE +0 -0
  30. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/README.md +0 -0
  31. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/crates/ferro-migrate/Cargo.toml +0 -0
  32. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/crates/ferro-migrate/src/lib.rs +0 -0
  33. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/crates/ferro-schema-ir/Cargo.toml +0 -0
  34. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/crates/ferro-schema-ir/src/lib.rs +0 -0
  35. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  36. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  37. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  38. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  39. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
  40. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/multiple_databases.py +0 -0
  41. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/mutations.py +0 -0
  42. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/pagination.py +0 -0
  43. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/predicates.py +0 -0
  44. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/predicates_annotated.py +0 -0
  45. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/quickstart.py +0 -0
  46. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/quickstart_annotated.py +0 -0
  47. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/raw_sql.py +0 -0
  48. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/relationships.py +0 -0
  49. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/relationships_annotated.py +0 -0
  50. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/soft_deletes.py +0 -0
  51. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/soft_deletes_annotated.py +0 -0
  52. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/testing_conftest.py +0 -0
  53. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/timestamps.py +0 -0
  54. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/timestamps_annotated.py +0 -0
  55. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/examples/transactions.py +0 -0
  56. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/connection.md +0 -0
  57. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/exceptions.md +0 -0
  58. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/fields.md +0 -0
  59. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/migrations.md +0 -0
  60. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/model.md +0 -0
  61. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/queries.md +0 -0
  62. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/raw-sql.md +0 -0
  63. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/relationships.md +0 -0
  64. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/api/transactions.md +0 -0
  65. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/changelog.md +0 -0
  66. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/concepts/architecture.md +0 -0
  67. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/concepts/backends.md +0 -0
  68. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/concepts/identity-map.md +0 -0
  69. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/concepts/performance.md +0 -0
  70. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/concepts/query-typing.md +0 -0
  71. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/concepts/type-safety.md +0 -0
  72. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/contributing.md +0 -0
  73. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/faq.md +0 -0
  74. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/getting-started/installation.md +0 -0
  75. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/getting-started/next-steps.md +0 -0
  76. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/getting-started/quickstart.md +0 -0
  77. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/migrations.md +0 -0
  78. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/models-and-fields.md +0 -0
  79. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/mutations.md +0 -0
  80. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/queries.md +0 -0
  81. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/raw-sql.md +0 -0
  82. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/guide/relationships.md +0 -0
  83. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
  84. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/migrating-to-v0-12-0.md +0 -0
  85. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/multiple-databases.md +0 -0
  86. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/pagination.md +0 -0
  87. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/soft-deletes.md +0 -0
  88. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/testing.md +0 -0
  89. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/howto/timestamps.md +0 -0
  90. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/index.md +0 -0
  91. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/roadmap.md +0 -0
  92. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/stylesheets/extra.css +0 -0
  93. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/pages/why-ferro.md +0 -0
  94. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  95. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  96. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  97. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  98. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  99. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  100. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
  101. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
  102. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/ir-first-migration-guide.md +0 -0
  103. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/plans/ir-first-release-checklist.md +0 -0
  104. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/rfc/ir-contracts-v1.md +0 -0
  105. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/README.md +0 -0
  106. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
  107. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  108. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  109. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  110. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  111. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
  112. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  113. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  114. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  115. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  116. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
  117. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/foreign-key-index.md +0 -0
  118. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  119. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/ir-invariants.md +0 -0
  120. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  121. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
  122. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/docs/solutions/patterns/typed-null-binds.md +0 -0
  123. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/justfile +0 -0
  124. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/scripts/demo_queries.py +0 -0
  125. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/scripts/demo_queries_pre_v012.py +0 -0
  126. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/backend.rs +0 -0
  127. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/codec.rs +0 -0
  128. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/connection.rs +0 -0
  129. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/__init__.py +0 -0
  130. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/_annotation_utils.py +0 -0
  131. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/_core.pyi +0 -0
  132. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/_deprecations.py +0 -0
  133. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/_shadow_fk_types.py +0 -0
  134. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/base.py +0 -0
  135. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/composite_indexes.py +0 -0
  136. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/composite_uniques.py +0 -0
  137. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/exceptions.py +0 -0
  138. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/fields.py +0 -0
  139. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/ir/__init__.py +0 -0
  140. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/ir/compiler.py +0 -0
  141. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/metaclass.py +0 -0
  142. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/migrations/__init__.py +0 -0
  143. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/migrations/alembic.py +0 -0
  144. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/models.py +0 -0
  145. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/py.typed +0 -0
  146. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/query/__init__.py +0 -0
  147. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/query/builder.py +0 -0
  148. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/query/nodes.py +0 -0
  149. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/raw.py +0 -0
  150. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/relations/__init__.py +0 -0
  151. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/relations/descriptors.py +0 -0
  152. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/schema_metadata.py +0 -0
  153. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/ferro/state.py +0 -0
  154. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/hydration.rs +0 -0
  155. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/introspect.rs +0 -0
  156. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/lib.rs +0 -0
  157. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/migrate.rs +0 -0
  158. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/query.rs +0 -0
  159. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/schema.rs +0 -0
  160. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/src/schema_bind.rs +0 -0
  161. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/__init__.py +0 -0
  162. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/conftest.py +0 -0
  163. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/db_backends.py +0 -0
  164. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/ir_vectors/README.md +0 -0
  165. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
  166. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/ir_vectors/query_user_compound_v1.json +0 -0
  167. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
  168. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
  169. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/shadow_reports/postgres.json +0 -0
  170. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/fixtures/shadow_reports/sqlite.json +0 -0
  171. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_aggregation.py +0 -0
  172. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_alembic_autogenerate.py +0 -0
  173. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_alembic_bridge.py +0 -0
  174. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_alembic_db_type.py +0 -0
  175. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_alembic_nullability.py +0 -0
  176. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_alembic_type_mapping.py +0 -0
  177. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_auto_migrate.py +0 -0
  178. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_bulk_update.py +0 -0
  179. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_composite_index.py +0 -0
  180. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_composite_unique.py +0 -0
  181. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_connection.py +0 -0
  182. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_connection_redaction.py +0 -0
  183. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_constraints.py +0 -0
  184. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_cross_emitter_parity.py +0 -0
  185. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_crud.py +0 -0
  186. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_db_backends.py +0 -0
  187. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_db_type_cross_emitter_parity.py +0 -0
  188. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_db_type_integration.py +0 -0
  189. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_db_type_typing.py +0 -0
  190. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_db_type_validation.py +0 -0
  191. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_deletion.py +0 -0
  192. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_deprecated_operator_inventory.py +0 -0
  193. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_deprecations.py +0 -0
  194. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_docs_examples.py +0 -0
  195. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_documentation_features.py +0 -0
  196. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_enum_cold_hydration.py +0 -0
  197. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_field_wrapper.py +0 -0
  198. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_framework_predicates.py +0 -0
  199. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_helpers.py +0 -0
  200. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_hydration.py +0 -0
  201. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_ir_vectors_contract.py +0 -0
  202. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_metaclass_internals.py +0 -0
  203. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_metadata.py +0 -0
  204. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_migrate_plan.py +0 -0
  205. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_models.py +0 -0
  206. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_named_connections_integration.py +0 -0
  207. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_one_to_one.py +0 -0
  208. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_query_builder.py +0 -0
  209. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_query_typing.py +0 -0
  210. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_raw_sql.py +0 -0
  211. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_refresh.py +0 -0
  212. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_relationship_engine.py +0 -0
  213. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_schema.py +0 -0
  214. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_schema_constraints.py +0 -0
  215. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_schema_db_type_metadata.py +0 -0
  216. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_schema_enum_annotations.py +0 -0
  217. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_shadow_fk_types.py +0 -0
  218. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_shadow_reports.py +0 -0
  219. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  220. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_static_contracts.py +0 -0
  221. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_string_search.py +0 -0
  222. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_structural_types.py +0 -0
  223. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_temporal_types.py +0 -0
  224. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_transactions.py +0 -0
  225. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/tests/test_typed_null_binds.py +0 -0
  226. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/uv.lock +0 -0
  227. {ferro_orm-0.12.2 → ferro_orm-0.12.3}/zensical.toml +0 -0
@@ -1,6 +1,19 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.12.3 (2026-06-24)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **session**: Reject close while transactions are active
9
+ ([#128](https://github.com/syn54x/ferro-orm/pull/128),
10
+ [`e46f183`](https://github.com/syn54x/ferro-orm/commit/e46f183fba7a3bcbc635f7e66502a77aeb56861d))
11
+
12
+ - **session**: Serialize concurrent close teardown
13
+ ([#129](https://github.com/syn54x/ferro-orm/pull/129),
14
+ [`7afa616`](https://github.com/syn54x/ferro-orm/commit/7afa6168abd2eb19b564fc1c2ab9956e03a20fe9))
15
+
16
+
4
17
  ## v0.12.2 (2026-06-23)
5
18
 
6
19
  ### Bug Fixes
@@ -288,7 +288,7 @@ dependencies = [
288
288
 
289
289
  [[package]]
290
290
  name = "ferro"
291
- version = "0.12.2"
291
+ version = "0.12.3"
292
292
  dependencies = [
293
293
  "dashmap",
294
294
  "ferro-migrate",
@@ -3,7 +3,7 @@ members = ["crates/ferro-schema-ir", "crates/ferro-migrate"]
3
3
 
4
4
  [package]
5
5
  name = "ferro"
6
- version = "0.12.2"
6
+ version = "0.12.3"
7
7
  edition = "2024"
8
8
  readme = "README.md"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.12.2
3
+ Version: 0.12.3
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'
@@ -139,6 +139,8 @@ class App:
139
139
 
140
140
  `Session.close()` is safe across asyncio contexts and idempotent. Prefer explicit `session=` routing when ambient context may be unavailable in the closing task.
141
141
 
142
+ Close raises `RuntimeError` if session-scoped transactions are still open — exit all `transaction()` blocks before closing the session.
143
+
142
144
  After a cross-context close, the enter task may still hold a stale ambient session object until a new session is opened; ambient operations then raise `RuntimeError` with a session-closed message (including when `using=` names another connection). If nested sessions are open, close inner sessions from the same task or via cross-context `close()` before closing the outer handle in the original task.
143
145
 
144
146
  Operations on a closed session handle (ambient or explicit `session=`) raise `RuntimeError` rather than falling back to legacy default routing.
@@ -40,6 +40,8 @@ async with ferro.engines.session("analytics"):
40
40
 
41
41
  You can also pass an explicit session handle (`transaction(session=my_session)`) when ambient context is different and you need a deterministic override.
42
42
 
43
+ `Session.close()` raises `RuntimeError` while session-scoped transactions are still open. Exit all `transaction()` blocks before closing the session.
44
+
43
45
  ## Raw SQL Inside a Transaction
44
46
 
45
47
  `transaction()` yields a `Transaction` handle exposing `execute` / `fetch_all` / `fetch_one` for raw SQL on the transaction's own connection — useful for Postgres GUCs (`set_config`, `SET LOCAL`), advisory locks, or any one-off statement that doesn't fit a model:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.12.2"
3
+ version = "0.12.3"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -28,6 +28,7 @@ class Session:
28
28
  _enter_task: asyncio.Task[Any] | None = field(
29
29
  default=None, repr=False, compare=False
30
30
  )
31
+ _close_lock: asyncio.Lock | None = field(default=None, repr=False, compare=False)
31
32
 
32
33
  async def __aenter__(self) -> "Session":
33
34
  self.session_id, resolved_name = _core_open_session(self.connection_name)
@@ -56,28 +57,33 @@ class Session:
56
57
 
57
58
  Raises:
58
59
  RuntimeError: If the ambient session in this asyncio context does not
59
- match this handle (same-context lifecycle misuse).
60
+ match this handle (same-context lifecycle misuse), or if
61
+ session-scoped transactions are still open.
60
62
  """
61
- if self.session_id is None and self._token is None:
62
- return
63
+ if self._close_lock is None:
64
+ self._close_lock = asyncio.Lock()
65
+
66
+ async with self._close_lock:
67
+ if self.session_id is None and self._token is None:
68
+ return
63
69
 
64
- self._assert_close_allowed()
70
+ self._assert_close_allowed()
65
71
 
66
- if self.session_id is not None:
67
- session_id = self.session_id
68
- _core_close_session(session_id)
69
- self.session_id = None
72
+ if self.session_id is not None:
73
+ session_id = self.session_id
74
+ _core_close_session(session_id)
75
+ self.session_id = None
70
76
 
71
- if self._token is None:
77
+ if self._token is None:
78
+ self._enter_context = None
79
+ self._enter_task = None
80
+ return
81
+
82
+ token = self._token
83
+ self._token = None
72
84
  self._enter_context = None
73
85
  self._enter_task = None
74
- return
75
-
76
- token = self._token
77
- self._token = None
78
- self._enter_context = None
79
- self._enter_task = None
80
- self._restore_ambient_session(token)
86
+ self._restore_ambient_session(token)
81
87
 
82
88
  def _assert_close_allowed(self) -> None:
83
89
  if self._token is None:
@@ -9,8 +9,8 @@ use crate::backend::{
9
9
  use crate::query::{QueryDef, query_def_from_ir_payload};
10
10
  use crate::state::{
11
11
  IDENTITY_MAP, MODEL_REGISTRY, SqlDialect, TRANSACTION_REGISTRY,
12
- TransactionConnection, TransactionHandle, connection_for_route, engine_for_connection,
13
- register_session, session_state, unregister_session,
12
+ TransactionConnection, TransactionHandle, connection_for_route, ensure_session_idle_for_close,
13
+ engine_for_connection, register_session, session_state, unregister_session,
14
14
  };
15
15
  use ferro_schema_ir::{IrEnvelope, QueryIrPayload};
16
16
  use pyo3::prelude::*;
@@ -833,6 +833,7 @@ pub fn open_session(using: Option<String>) -> PyResult<(String, String)> {
833
833
 
834
834
  #[pyfunction]
835
835
  pub fn close_session(session_id: String) -> PyResult<()> {
836
+ ensure_session_idle_for_close(&session_id)?;
836
837
  if !unregister_session(&session_id) {
837
838
  return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
838
839
  "Session '{}' is not active",
@@ -199,6 +199,20 @@ pub fn session_state(session_id: &str) -> PyResult<Arc<SessionState>> {
199
199
  })
200
200
  }
201
201
 
202
+ pub(crate) const SESSION_CLOSE_ACTIVE_TRANSACTIONS_MSG: &str =
203
+ "Cannot close session while transactions are active. \
204
+ Exit all transaction() blocks before closing the session.";
205
+
206
+ pub fn ensure_session_idle_for_close(session_id: &str) -> PyResult<()> {
207
+ let session = session_state(session_id)?;
208
+ if !session.transaction_registry.is_empty() {
209
+ return Err(pyo3::exceptions::PyRuntimeError::new_err(
210
+ SESSION_CLOSE_ACTIVE_TRANSACTIONS_MSG,
211
+ ));
212
+ }
213
+ Ok(())
214
+ }
215
+
202
216
  /// A Rust-native representation of database values.
203
217
  ///
204
218
  /// Used during the GIL-free parsing phase to move data from the database
@@ -262,3 +276,53 @@ impl RustValue {
262
276
  }
263
277
  }
264
278
  }
279
+
280
+ #[cfg(test)]
281
+ mod session_close_tests {
282
+ use super::{TransactionHandle, register_session, unregister_session, SESSION_REGISTRY};
283
+ use crate::backend::EngineHandle;
284
+ use sqlx::sqlite::SqlitePoolOptions;
285
+
286
+ #[tokio::test]
287
+ async fn close_guard_uses_transaction_registry_emptiness() {
288
+ let session_id = register_session("default".to_string());
289
+
290
+ let pool = SqlitePoolOptions::new()
291
+ .max_connections(1)
292
+ .connect("sqlite::memory:")
293
+ .await
294
+ .expect("sqlite memory pool");
295
+ let engine = EngineHandle::new_sqlite(pool);
296
+ let conn = engine
297
+ .begin_transaction_connection()
298
+ .await
299
+ .expect("begin transaction connection");
300
+
301
+ {
302
+ let session = SESSION_REGISTRY
303
+ .get(&session_id)
304
+ .expect("session registered")
305
+ .value()
306
+ .clone();
307
+ session.transaction_registry.insert(
308
+ "tx-test".to_string(),
309
+ TransactionHandle::root(conn, "default".to_string()),
310
+ );
311
+ assert!(!session.transaction_registry.is_empty());
312
+ }
313
+
314
+ SESSION_REGISTRY
315
+ .get(&session_id)
316
+ .expect("session registered")
317
+ .transaction_registry
318
+ .clear();
319
+ assert!(
320
+ SESSION_REGISTRY
321
+ .get(&session_id)
322
+ .expect("session registered")
323
+ .transaction_registry
324
+ .is_empty()
325
+ );
326
+ assert!(unregister_session(&session_id));
327
+ }
328
+ }
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import re
2
3
  import warnings
3
4
  from contextlib import AsyncExitStack
4
5
  from typing import Annotated
@@ -182,6 +183,35 @@ async def test_session_close_is_idempotent(tmp_path):
182
183
  assert session.session_id is None
183
184
 
184
185
 
186
+ @pytest.mark.asyncio
187
+ async def test_concurrent_close_same_loop_is_idempotent(tmp_path):
188
+ app_db = tmp_path / "app.db"
189
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
190
+
191
+ session = ferro.engines.session("default")
192
+ await session.__aenter__()
193
+ await SessionMarker.create(id=1, label="demo")
194
+
195
+ barrier = asyncio.Barrier(2)
196
+
197
+ async def close_after_barrier() -> None:
198
+ await barrier.wait()
199
+ await session.close()
200
+
201
+ results = await asyncio.gather(
202
+ close_after_barrier(),
203
+ close_after_barrier(),
204
+ return_exceptions=True,
205
+ )
206
+ assert not any(isinstance(r, BaseException) for r in results)
207
+ assert session.session_id is None
208
+
209
+ await session.close()
210
+
211
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
212
+ await SessionMarker.all()
213
+
214
+
185
215
  @pytest.mark.asyncio
186
216
  async def test_nested_session_cross_context_close_inner_then_outer(tmp_path):
187
217
  app_db = tmp_path / "app.db"
@@ -345,6 +375,144 @@ async def test_transaction_after_cross_context_close_raises(tmp_path):
345
375
  pass
346
376
 
347
377
 
378
+ _ACTIVE_TX_CLOSE_MSG = (
379
+ "Cannot close session while transactions are active. "
380
+ "Exit all transaction() blocks before closing the session."
381
+ )
382
+
383
+
384
+ @pytest.mark.asyncio
385
+ async def test_close_rejected_during_active_transaction_cross_context(tmp_path):
386
+ app_db = tmp_path / "app.db"
387
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
388
+
389
+ session = ferro.engines.session("default")
390
+ await session.__aenter__()
391
+
392
+ async with ferro.transaction():
393
+ close_error: BaseException | None = None
394
+
395
+ async def try_close() -> None:
396
+ nonlocal close_error
397
+ try:
398
+ await session.close()
399
+ except BaseException as exc:
400
+ close_error = exc
401
+
402
+ await asyncio.create_task(try_close())
403
+
404
+ assert isinstance(close_error, RuntimeError)
405
+ assert str(close_error) == _ACTIVE_TX_CLOSE_MSG
406
+ assert session.session_id is not None
407
+
408
+ created = await SessionMarker.create(label="during-tx")
409
+ assert created.id is not None
410
+
411
+ fetched = await SessionMarker.where(lambda t: t.label == "during-tx").first()
412
+ assert fetched is not None
413
+
414
+ await session.close()
415
+ assert session.session_id is None
416
+
417
+
418
+ @pytest.mark.asyncio
419
+ async def test_close_rejected_during_active_transaction_same_context(tmp_path):
420
+ app_db = tmp_path / "app.db"
421
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
422
+
423
+ session = ferro.engines.session("default")
424
+ await session.__aenter__()
425
+
426
+ async with ferro.transaction():
427
+ with pytest.raises(RuntimeError, match=re.escape(_ACTIVE_TX_CLOSE_MSG)):
428
+ await session.close()
429
+ assert session.session_id is not None
430
+ created = await SessionMarker.create(label="same-context")
431
+ assert created.id is not None
432
+
433
+ fetched = await SessionMarker.where(lambda t: t.label == "same-context").first()
434
+ assert fetched is not None
435
+
436
+ await session.close()
437
+ assert session.session_id is None
438
+
439
+
440
+ @pytest.mark.asyncio
441
+ async def test_close_rejected_during_active_transaction_rollback(tmp_path):
442
+ app_db = tmp_path / "app.db"
443
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
444
+
445
+ session = ferro.engines.session("default")
446
+ await session.__aenter__()
447
+
448
+ try:
449
+ async with ferro.transaction():
450
+ with pytest.raises(RuntimeError, match=re.escape(_ACTIVE_TX_CLOSE_MSG)):
451
+ await session.close()
452
+ assert session.session_id is not None
453
+ await SessionMarker.create(label="rolled-back")
454
+ raise RuntimeError("abort tx")
455
+ except RuntimeError:
456
+ pass
457
+
458
+ assert session.session_id is not None
459
+ assert not await SessionMarker.where(lambda t: t.label == "rolled-back").exists()
460
+
461
+ await session.close()
462
+ assert session.session_id is None
463
+
464
+
465
+ @pytest.mark.asyncio
466
+ async def test_close_rejected_with_explicit_transaction_session(tmp_path):
467
+ app_db = tmp_path / "app.db"
468
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
469
+
470
+ session = ferro.engines.session("default")
471
+ await session.__aenter__()
472
+
473
+ async with ferro.transaction(session=session):
474
+ with pytest.raises(RuntimeError, match=re.escape(_ACTIVE_TX_CLOSE_MSG)):
475
+ await session.close()
476
+ assert session.session_id is not None
477
+ created = await SessionMarker.create(label="explicit-session")
478
+ assert created.id is not None
479
+
480
+ fetched = await SessionMarker.where(lambda t: t.label == "explicit-session").first()
481
+ assert fetched is not None
482
+
483
+ await session.close()
484
+ assert session.session_id is None
485
+
486
+
487
+ @pytest.mark.asyncio
488
+ async def test_close_rejected_during_nested_transaction(tmp_path):
489
+ app_db = tmp_path / "app.db"
490
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
491
+
492
+ session = ferro.engines.session("default")
493
+ await session.__aenter__()
494
+
495
+ async with ferro.transaction():
496
+ async with ferro.transaction():
497
+ with pytest.raises(RuntimeError, match=re.escape(_ACTIVE_TX_CLOSE_MSG)):
498
+ await session.close()
499
+ assert session.session_id is not None
500
+ inner = await SessionMarker.create(label="nested-inner")
501
+ assert inner.id is not None
502
+
503
+ with pytest.raises(RuntimeError, match=re.escape(_ACTIVE_TX_CLOSE_MSG)):
504
+ await session.close()
505
+ assert session.session_id is not None
506
+ outer = await SessionMarker.create(label="nested-outer")
507
+ assert outer.id is not None
508
+
509
+ assert await SessionMarker.where(lambda t: t.label == "nested-inner").exists()
510
+ assert await SessionMarker.where(lambda t: t.label == "nested-outer").exists()
511
+
512
+ await session.close()
513
+ assert session.session_id is None
514
+
515
+
348
516
  @pytest.mark.asyncio
349
517
  async def test_aexit_preserves_body_exception_when_close_fails(tmp_path):
350
518
  from ferro.state import _CURRENT_SESSION
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes