ferro-orm 0.10.1__tar.gz → 0.10.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/CHANGELOG.md +9 -0
  2. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/Cargo.lock +1 -1
  3. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/Cargo.toml +1 -1
  4. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/PKG-INFO +1 -1
  5. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +2 -2
  6. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/patterns/typed-null-binds.md +2 -2
  7. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/pyproject.toml +1 -1
  8. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/operations.rs +123 -0
  9. ferro_orm-0.10.2/tests/test_sqlite_alembic_reconnect_hydration.py +272 -0
  10. ferro_orm-0.10.1/tests/test_sqlite_null_hydration.py +0 -71
  11. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/PERMISSIONS.md +0 -0
  14. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/PYPI_CHECKLIST.md +0 -0
  15. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/PYPI_SETUP.md +0 -0
  16. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/generated/wheels.generated.yml +0 -0
  17. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/pull_request_template.md +0 -0
  18. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/workflows/ci.yml +0 -0
  19. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/workflows/packaging-smoke.yml +0 -0
  20. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/workflows/publish-docs.yml +0 -0
  21. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/workflows/publish.yml +0 -0
  22. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.github/workflows/release.yml +0 -0
  23. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.gitignore +0 -0
  24. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.pre-commit-config.yaml +0 -0
  25. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/.python-version +0 -0
  26. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/AGENTS.md +0 -0
  27. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/CONTRIBUTING.md +0 -0
  28. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/LICENSE +0 -0
  29. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/README.md +0 -0
  30. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/TEST_RESULTS.md +0 -0
  31. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/exceptions.md +0 -0
  32. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/fields.md +0 -0
  33. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/model.md +0 -0
  34. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/query.md +0 -0
  35. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/raw-sql.md +0 -0
  36. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/relationships.md +0 -0
  37. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/transactions.md +0 -0
  38. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/api/utilities.md +0 -0
  39. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  40. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  41. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  42. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  43. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/changelog.md +0 -0
  44. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/coming-soon.md +0 -0
  45. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/concepts/architecture.md +0 -0
  46. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/concepts/identity-map.md +0 -0
  47. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/concepts/performance.md +0 -0
  48. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/concepts/query-typing.md +0 -0
  49. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/concepts/type-safety.md +0 -0
  50. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/contributing.md +0 -0
  51. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/faq.md +0 -0
  52. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/getting-started/installation.md +0 -0
  53. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/getting-started/next-steps.md +0 -0
  54. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/getting-started/tutorial.md +0 -0
  55. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/backend.md +0 -0
  56. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/database.md +0 -0
  57. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/migrations.md +0 -0
  58. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/models-and-fields.md +0 -0
  59. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/mutations.md +0 -0
  60. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/queries.md +0 -0
  61. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/relationships.md +0 -0
  62. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/guide/transactions.md +0 -0
  63. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/howto/multiple-databases.md +0 -0
  64. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/howto/pagination.md +0 -0
  65. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/howto/soft-deletes.md +0 -0
  66. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/howto/testing.md +0 -0
  67. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/howto/timestamps.md +0 -0
  68. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/index.md +0 -0
  69. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/migration-sqlalchemy.md +0 -0
  70. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  71. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  72. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  73. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  74. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  75. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  76. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/README.md +0 -0
  77. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  78. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  79. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  80. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  81. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  82. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  83. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
  84. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  85. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  86. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/stylesheets/extra.css +0 -0
  87. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/docs/why-ferro.md +0 -0
  88. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/justfile +0 -0
  89. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/mkdocs.yml +0 -0
  90. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/scripts/demo_queries.py +0 -0
  91. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/backend.rs +0 -0
  92. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/connection.rs +0 -0
  93. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/__init__.py +0 -0
  94. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/_annotation_utils.py +0 -0
  95. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/_core.pyi +0 -0
  96. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/_shadow_fk_types.py +0 -0
  97. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/base.py +0 -0
  98. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/composite_indexes.py +0 -0
  99. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/composite_uniques.py +0 -0
  100. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/exceptions.py +0 -0
  101. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/fields.py +0 -0
  102. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/metaclass.py +0 -0
  103. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/migrations/__init__.py +0 -0
  104. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/migrations/alembic.py +0 -0
  105. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/models.py +0 -0
  106. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/py.typed +0 -0
  107. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/query/__init__.py +0 -0
  108. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/query/builder.py +0 -0
  109. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/query/nodes.py +0 -0
  110. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/raw.py +0 -0
  111. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/relations/__init__.py +0 -0
  112. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/relations/descriptors.py +0 -0
  113. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/schema_metadata.py +0 -0
  114. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/ferro/state.py +0 -0
  115. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/lib.rs +0 -0
  116. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/query.rs +0 -0
  117. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/schema.rs +0 -0
  118. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/src/state.rs +0 -0
  119. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/__init__.py +0 -0
  120. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/conftest.py +0 -0
  121. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/db_backends.py +0 -0
  122. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_aggregation.py +0 -0
  123. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_alembic_autogenerate.py +0 -0
  124. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_alembic_bridge.py +0 -0
  125. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_alembic_db_type.py +0 -0
  126. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_alembic_nullability.py +0 -0
  127. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_alembic_type_mapping.py +0 -0
  128. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_auto_migrate.py +0 -0
  129. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_bulk_update.py +0 -0
  130. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_composite_index.py +0 -0
  131. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_composite_unique.py +0 -0
  132. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_connection.py +0 -0
  133. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_connection_redaction.py +0 -0
  134. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_constraints.py +0 -0
  135. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_cross_emitter_parity.py +0 -0
  136. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_crud.py +0 -0
  137. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_db_backends.py +0 -0
  138. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
  139. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_db_type_integration.py +0 -0
  140. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_db_type_typing.py +0 -0
  141. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_db_type_validation.py +0 -0
  142. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_deletion.py +0 -0
  143. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_docs_examples.py +0 -0
  144. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_documentation_features.py +0 -0
  145. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_field_wrapper.py +0 -0
  146. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_helpers.py +0 -0
  147. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_hydration.py +0 -0
  148. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_metaclass_internals.py +0 -0
  149. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_metadata.py +0 -0
  150. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_models.py +0 -0
  151. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_named_connections_integration.py +0 -0
  152. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_one_to_one.py +0 -0
  153. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_query_builder.py +0 -0
  154. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_query_typing.py +0 -0
  155. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_raw_sql.py +0 -0
  156. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_refresh.py +0 -0
  157. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_relationship_engine.py +0 -0
  158. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_schema.py +0 -0
  159. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_schema_constraints.py +0 -0
  160. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_schema_db_type_metadata.py +0 -0
  161. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_schema_enum_annotations.py +0 -0
  162. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_shadow_fk_types.py +0 -0
  163. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_static_contracts.py +0 -0
  164. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_string_search.py +0 -0
  165. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_structural_types.py +0 -0
  166. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_temporal_types.py +0 -0
  167. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_transactions.py +0 -0
  168. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/tests/test_typed_null_binds.py +0 -0
  169. {ferro_orm-0.10.1 → ferro_orm-0.10.2}/uv.lock +0 -0
@@ -1,6 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.10.2 (2026-05-19)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Hydrate SQLite INTEGER-backed Decimal columns on reconnect
9
+ ([#59](https://github.com/syn54x/ferro-orm/pull/59),
10
+ [`6f13906`](https://github.com/syn54x/ferro-orm/commit/6f13906850300bc9e85c1274763c49bc5b318b5d))
11
+
12
+
4
13
  ## v0.10.1 (2026-05-19)
5
14
 
6
15
  ### Bug Fixes
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.10.1"
297
+ version = "0.10.2"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.10.1"
3
+ version = "0.10.2"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.10.1
3
+ Version: 0.10.2
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -5,7 +5,7 @@ tags: [gotcha, sqlite, hydration, bridge, rust, sqlalchemy, alembic, datetime, f
5
5
  related_files:
6
6
  - src/backend.rs
7
7
  - src/operations.rs
8
- - tests/test_sqlite_null_hydration.py
8
+ - tests/test_sqlite_alembic_reconnect_hydration.py
9
9
  related_issues: [56]
10
10
  related_prs: [57]
11
11
  captured: 2026-05-18
@@ -62,7 +62,7 @@ by treating `0` as missing.
62
62
 
63
63
  - Rust: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`,
64
64
  `engine_handle_fetches_sqlite_non_null_zero_integer` in `src/backend.rs`.
65
- - Python: `tests/test_sqlite_null_hydration.py` (Alembic `create_all` + reconnect).
65
+ - Python: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic `create_all` + reconnect).
66
66
  Requires `aiosqlite` and `greenlet` in `ci-test` / `dev` so CI does not skip.
67
67
 
68
68
  ## How to recognize
@@ -7,7 +7,7 @@ related_files:
7
7
  - src/operations.rs
8
8
  - src/query.rs
9
9
  - tests/test_typed_null_binds.py
10
- - tests/test_sqlite_null_hydration.py
10
+ - tests/test_sqlite_alembic_reconnect_hydration.py
11
11
  related_issues: [38, 40, 56]
12
12
  related_prs: []
13
13
  captured: 2026-04-29
@@ -112,7 +112,7 @@ typed `try_get`. Non-null integer `0` is unchanged; only SQL `NULL` is affected.
112
112
  | Fetch (read) | `materialize_engine_row` | `is_null()` before `decode_non_null_*`|
113
113
 
114
114
  Rust regression: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`.
115
- Integration: `tests/test_sqlite_null_hydration.py` (Alembic schema + reconnect).
115
+ Integration: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic schema + reconnect).
116
116
 
117
117
  ## Raw-SQL boundary (explicit exception)
118
118
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.10.1"
3
+ version = "0.10.2"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -193,6 +193,7 @@ fn engine_value_to_rust_value(
193
193
 
194
194
  if is_decimal {
195
195
  return match value {
196
+ EngineValue::I64(v) => RustValue::Decimal(v.to_string()),
196
197
  EngineValue::F64(v) => RustValue::Decimal(v.to_string()),
197
198
  EngineValue::String(v) => RustValue::Decimal(v),
198
199
  _ => RustValue::None,
@@ -3077,3 +3078,125 @@ mod raw_sql_tests {
3077
3078
  });
3078
3079
  }
3079
3080
  }
3081
+
3082
+ #[cfg(test)]
3083
+ mod engine_value_to_rust_value_tests {
3084
+ use super::engine_value_to_rust_value;
3085
+ use crate::backend::EngineValue;
3086
+ use crate::state::RustValue;
3087
+
3088
+ fn decimal_schema() -> serde_json::Value {
3089
+ serde_json::json!({
3090
+ "properties": {
3091
+ "hours": {
3092
+ "format": "decimal",
3093
+ "anyOf": [
3094
+ {"type": "number"},
3095
+ {"type": "string", "pattern": "^-?\\d+(\\.\\d+)?$"}
3096
+ ]
3097
+ }
3098
+ }
3099
+ })
3100
+ }
3101
+
3102
+ #[test]
3103
+ fn decimal_column_maps_sqlite_integer_affinity_to_decimal() {
3104
+ let schema = decimal_schema();
3105
+ let out = engine_value_to_rust_value(EngineValue::I64(3), &schema, "hours");
3106
+ assert!(matches!(out, RustValue::Decimal(ref s) if s == "3"));
3107
+ }
3108
+
3109
+ #[test]
3110
+ fn decimal_column_maps_real_and_text() {
3111
+ let schema = decimal_schema();
3112
+ let from_real =
3113
+ engine_value_to_rust_value(EngineValue::F64(1.5), &schema, "hours");
3114
+ assert!(matches!(from_real, RustValue::Decimal(ref s) if s == "1.5"));
3115
+ let from_text =
3116
+ engine_value_to_rust_value(EngineValue::String("2.25".into()), &schema, "hours");
3117
+ assert!(matches!(from_text, RustValue::Decimal(ref s) if s == "2.25"));
3118
+ }
3119
+
3120
+ fn datetime_schema() -> serde_json::Value {
3121
+ serde_json::json!({
3122
+ "properties": {
3123
+ "happened_at": {"type": "string", "format": "date-time"}
3124
+ }
3125
+ })
3126
+ }
3127
+
3128
+ fn binary_schema() -> serde_json::Value {
3129
+ serde_json::json!({
3130
+ "properties": {
3131
+ "data": {"type": "string", "format": "binary"}
3132
+ }
3133
+ })
3134
+ }
3135
+
3136
+ fn bool_schema() -> serde_json::Value {
3137
+ serde_json::json!({
3138
+ "properties": {
3139
+ "is_active": {"type": "boolean"}
3140
+ }
3141
+ })
3142
+ }
3143
+
3144
+ fn json_schema() -> serde_json::Value {
3145
+ serde_json::json!({
3146
+ "properties": {
3147
+ "payload": {"type": "object"}
3148
+ }
3149
+ })
3150
+ }
3151
+
3152
+ #[test]
3153
+ fn datetime_column_only_accepts_string_engine_values() {
3154
+ let schema = datetime_schema();
3155
+ let ok = engine_value_to_rust_value(
3156
+ EngineValue::String("2026-04-24T18:30:00+00:00".into()),
3157
+ &schema,
3158
+ "happened_at",
3159
+ );
3160
+ assert!(matches!(ok, RustValue::DateTime(_)));
3161
+ let from_int = engine_value_to_rust_value(EngineValue::I64(1713984600), &schema, "happened_at");
3162
+ assert!(matches!(from_int, RustValue::BigInt(1713984600)));
3163
+ }
3164
+
3165
+ #[test]
3166
+ fn binary_column_maps_bytes_and_text() {
3167
+ let schema = binary_schema();
3168
+ let from_bytes =
3169
+ engine_value_to_rust_value(EngineValue::Bytes(vec![1, 2, 3]), &schema, "data");
3170
+ assert!(matches!(from_bytes, RustValue::Blob(v) if v == vec![1, 2, 3]));
3171
+ let from_text =
3172
+ engine_value_to_rust_value(EngineValue::String("abc".into()), &schema, "data");
3173
+ assert!(matches!(from_text, RustValue::Blob(v) if v == b"abc".to_vec()));
3174
+ let from_int = engine_value_to_rust_value(EngineValue::I64(1), &schema, "data");
3175
+ assert!(matches!(from_int, RustValue::None));
3176
+ }
3177
+
3178
+ #[test]
3179
+ fn bool_column_maps_integer_and_bool() {
3180
+ let schema = bool_schema();
3181
+ assert!(matches!(
3182
+ engine_value_to_rust_value(EngineValue::I64(1), &schema, "is_active"),
3183
+ RustValue::Bool(true)
3184
+ ));
3185
+ assert!(matches!(
3186
+ engine_value_to_rust_value(EngineValue::Bool(false), &schema, "is_active"),
3187
+ RustValue::Bool(false)
3188
+ ));
3189
+ }
3190
+
3191
+ #[test]
3192
+ fn json_column_parses_string_payload() {
3193
+ let schema = json_schema();
3194
+ let out = engine_value_to_rust_value(
3195
+ EngineValue::String(r#"{"k":"v"}"#.into()),
3196
+ &schema,
3197
+ "payload",
3198
+ );
3199
+ assert!(matches!(out, RustValue::Json(v) if v.get("k").and_then(|x| x.as_str()) == Some("v")));
3200
+ }
3201
+
3202
+ }
@@ -0,0 +1,272 @@
1
+ """SQLite + Alembic schema: typed column hydration after reset_engine() / reconnect.
2
+
3
+ Most Ferro tests use auto_migrate=True (Rust DDL) and same-session reads (identity map).
4
+ Bugs like #56 (NULL -> int 0) and #58 (INTEGER Decimal -> None) only appear when:
5
+
6
+ - Schema comes from Alembic ``create_all`` (``auto_migrate=False``)
7
+ - Rows are re-fetched on a fresh ``ferro.connect()``
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ import tempfile
15
+ from collections.abc import AsyncIterator
16
+ from contextlib import asynccontextmanager
17
+ from datetime import UTC, date, datetime
18
+ from decimal import Decimal
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from uuid import UUID, uuid4
22
+
23
+ import ferro
24
+ import pytest
25
+ from ferro import Field, Model, clear_registry, reset_engine
26
+ from ferro.migrations.alembic import get_metadata
27
+ from sqlalchemy import pool
28
+ from sqlalchemy.engine.url import URL
29
+ from sqlalchemy.ext.asyncio import create_async_engine
30
+
31
+ pytest.importorskip("aiosqlite")
32
+ pytest.importorskip("greenlet")
33
+
34
+ pytestmark = pytest.mark.sqlite_only
35
+
36
+
37
+ @pytest.fixture(autouse=True)
38
+ def cleanup() -> None:
39
+ from ferro.state import _JOIN_TABLE_REGISTRY, _MODEL_REGISTRY_PY, _PENDING_RELATIONS
40
+
41
+ _MODEL_REGISTRY_PY.clear()
42
+ _PENDING_RELATIONS.clear()
43
+ _JOIN_TABLE_REGISTRY.clear()
44
+ reset_engine()
45
+ clear_registry()
46
+ yield
47
+
48
+
49
+ def _sqlite_url(path: Path) -> str:
50
+ return URL.create(
51
+ drivername="sqlite+aiosqlite",
52
+ database=str(path.resolve()),
53
+ ).render_as_string(hide_password=False)
54
+
55
+
56
+ @asynccontextmanager
57
+ async def _alembic_sqlite_db() -> AsyncIterator[tuple[str, Path]]:
58
+ with tempfile.TemporaryDirectory() as tmp:
59
+ db_path = Path(tmp) / "app.db"
60
+ uri = f"sqlite:{db_path}?mode=rwc"
61
+ engine = create_async_engine(_sqlite_url(db_path), poolclass=pool.NullPool)
62
+ async with engine.begin() as conn:
63
+ await conn.run_sync(lambda sync_conn: get_metadata().create_all(sync_conn))
64
+ await engine.dispose()
65
+ yield uri, db_path
66
+
67
+
68
+ async def _reload_row[M: Model](model: type[M], uri: str, row_id: str) -> M:
69
+ reset_engine()
70
+ await ferro.connect(uri, auto_migrate=False)
71
+ row = await model.where(model.id == row_id).first() # type: ignore[attr-defined]
72
+ assert row is not None
73
+ return row
74
+
75
+
76
+ def _sqlite_typeof(db_path: Path, table: str, column: str, row_id: str) -> tuple[Any, str]:
77
+ with sqlite3.connect(db_path) as conn:
78
+ value, affinity = conn.execute(
79
+ f"SELECT {column}, typeof({column}) FROM {table} WHERE id = ?",
80
+ (row_id,),
81
+ ).fetchone()
82
+ return value, affinity
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_null_optional_datetime_is_none_not_zero() -> None:
87
+ """Issue #56: NULL DATETIME must not hydrate as int(0)."""
88
+
89
+ class Client(Model):
90
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
91
+ name: str
92
+ archived_at: datetime | None = None
93
+
94
+ async with _alembic_sqlite_db() as (uri, db_path):
95
+ await ferro.connect(uri, auto_migrate=False)
96
+ created = await Client.create(name="Acme")
97
+ row = await _reload_row(Client, uri, created.id)
98
+ raw, affinity = _sqlite_typeof(db_path, "client", "archived_at", created.id)
99
+
100
+ assert raw is None
101
+ assert row.archived_at is None
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_non_null_datetime_round_trips() -> None:
106
+ class Event(Model):
107
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
108
+ happened_at: datetime = Field(default_factory=lambda: datetime(2026, 4, 24, 18, 30, tzinfo=UTC))
109
+
110
+ async with _alembic_sqlite_db() as (uri, db_path):
111
+ await ferro.connect(uri, auto_migrate=False)
112
+ expected = datetime(2026, 4, 24, 18, 30, tzinfo=UTC)
113
+ created = await Event.create(happened_at=expected)
114
+ row = await _reload_row(Event, uri, created.id)
115
+ _sqlite_typeof(db_path, "event", "happened_at", created.id)
116
+
117
+ assert row.happened_at == expected
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_non_null_date_round_trips() -> None:
122
+ class Day(Model):
123
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
124
+ on_date: date
125
+
126
+ async with _alembic_sqlite_db() as (uri, db_path):
127
+ await ferro.connect(uri, auto_migrate=False)
128
+ expected = date(2026, 4, 24)
129
+ created = await Day.create(on_date=expected)
130
+ row = await _reload_row(Day, uri, created.id)
131
+ _sqlite_typeof(db_path, "day", "on_date", created.id)
132
+
133
+ assert row.on_date == expected
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ @pytest.mark.parametrize(
138
+ ("hours", "expected"),
139
+ [
140
+ (Decimal(3), Decimal(3)),
141
+ (Decimal("1.5"), Decimal("1.5")),
142
+ (Decimal("0"), Decimal("0")),
143
+ ],
144
+ ids=["integer_affinity", "real_affinity", "decimal_zero"],
145
+ )
146
+ async def test_decimal_round_trips(hours: Decimal, expected: Decimal) -> None:
147
+ """Issue #58 class: NUMERIC columns must survive SQLite affinity + reconnect."""
148
+
149
+ class Widget(Model):
150
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
151
+ hours: Decimal
152
+
153
+ async with _alembic_sqlite_db() as (uri, db_path):
154
+ await ferro.connect(uri, auto_migrate=False)
155
+ created = await Widget.create(hours=hours)
156
+ row = await _reload_row(Widget, uri, created.id)
157
+ raw, affinity = _sqlite_typeof(db_path, "widget", "hours", created.id)
158
+
159
+ assert row.hours == expected
160
+ if hours == Decimal(3):
161
+ assert raw == 3
162
+ assert affinity == "integer"
163
+
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_decimal_string_literal_round_trips() -> None:
167
+ """String INSERT into NUMERIC may coerce affinity; value must still hydrate."""
168
+
169
+ class Widget(Model):
170
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
171
+ hours: Decimal
172
+
173
+ row_id = str(uuid4())
174
+ async with _alembic_sqlite_db() as (uri, db_path):
175
+ with sqlite3.connect(db_path) as conn:
176
+ conn.execute(
177
+ "INSERT INTO widget (id, hours) VALUES (?, ?)",
178
+ (row_id, "12.34"),
179
+ )
180
+ await ferro.connect(uri, auto_migrate=False)
181
+ row = await _reload_row(Widget, uri, row_id)
182
+ raw, affinity = _sqlite_typeof(db_path, "widget", "hours", row_id)
183
+
184
+ assert row.hours == Decimal("12.34")
185
+ assert affinity in ("text", "real")
186
+ assert raw in ("12.34", 12.34)
187
+
188
+
189
+ @pytest.mark.asyncio
190
+ async def test_optional_decimal_null_is_none() -> None:
191
+ class Line(Model):
192
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
193
+ amount: Decimal | None = None
194
+
195
+ async with _alembic_sqlite_db() as (uri, db_path):
196
+ await ferro.connect(uri, auto_migrate=False)
197
+ created = await Line.create()
198
+ row = await _reload_row(Line, uri, created.id)
199
+ raw, _ = _sqlite_typeof(db_path, "line", "amount", created.id)
200
+
201
+ assert raw is None
202
+ assert row.amount is None
203
+
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_bool_round_trips() -> None:
207
+ class Flag(Model):
208
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
209
+ is_active: bool
210
+
211
+ async with _alembic_sqlite_db() as (uri, db_path):
212
+ await ferro.connect(uri, auto_migrate=False)
213
+ created = await Flag.create(is_active=True)
214
+ row = await _reload_row(Flag, uri, created.id)
215
+ raw, affinity = _sqlite_typeof(db_path, "flag", "is_active", created.id)
216
+
217
+ assert row.is_active is True
218
+ assert raw in (1, True)
219
+ assert affinity == "integer"
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_uuid_round_trips() -> None:
224
+ class Token(Model):
225
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
226
+ subject_id: UUID
227
+
228
+ token = uuid4()
229
+ async with _alembic_sqlite_db() as (uri, db_path):
230
+ await ferro.connect(uri, auto_migrate=False)
231
+ created = await Token.create(subject_id=token)
232
+ row = await _reload_row(Token, uri, created.id)
233
+ _sqlite_typeof(db_path, "token", "subject_id", created.id)
234
+
235
+ assert row.subject_id == token
236
+
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_json_dict_round_trips() -> None:
240
+ class Doc(Model):
241
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
242
+ payload: dict[str, str]
243
+
244
+ data = {"k": "v", "n": "2"}
245
+ async with _alembic_sqlite_db() as (uri, db_path):
246
+ await ferro.connect(uri, auto_migrate=False)
247
+ created = await Doc.create(payload=data)
248
+ row = await _reload_row(Doc, uri, created.id)
249
+ raw, affinity = _sqlite_typeof(db_path, "doc", "payload", created.id)
250
+
251
+ assert row.payload == data
252
+ assert affinity == "text"
253
+ assert json.loads(raw) == data
254
+
255
+
256
+ @pytest.mark.asyncio
257
+ async def test_non_null_int_zero_round_trips() -> None:
258
+ """Legitimate integer 0 must not be confused with NULL (#56 regression guard)."""
259
+
260
+ class Counter(Model):
261
+ id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
262
+ score: int
263
+
264
+ async with _alembic_sqlite_db() as (uri, db_path):
265
+ await ferro.connect(uri, auto_migrate=False)
266
+ created = await Counter.create(score=0)
267
+ row = await _reload_row(Counter, uri, created.id)
268
+ raw, affinity = _sqlite_typeof(db_path, "counter", "score", created.id)
269
+
270
+ assert row.score == 0
271
+ assert raw == 0
272
+ assert affinity == "integer"
@@ -1,71 +0,0 @@
1
- """SQLite NULL columns must hydrate as None, not int 0 (issue #56)."""
2
-
3
- from __future__ import annotations
4
-
5
- import sqlite3
6
- import tempfile
7
- from datetime import datetime
8
- from pathlib import Path
9
- from uuid import uuid4
10
-
11
- import ferro
12
- import pytest
13
- from ferro import Field, Model, clear_registry, reset_engine
14
- from ferro.migrations.alembic import get_metadata
15
- from sqlalchemy import pool
16
- from sqlalchemy.engine.url import URL
17
- from sqlalchemy.ext.asyncio import create_async_engine
18
-
19
- pytest.importorskip("aiosqlite")
20
- pytest.importorskip("greenlet")
21
-
22
-
23
- @pytest.fixture(autouse=True)
24
- def cleanup():
25
- from ferro.state import _JOIN_TABLE_REGISTRY, _MODEL_REGISTRY_PY, _PENDING_RELATIONS
26
-
27
- _MODEL_REGISTRY_PY.clear()
28
- _PENDING_RELATIONS.clear()
29
- _JOIN_TABLE_REGISTRY.clear()
30
- reset_engine()
31
- clear_registry()
32
- yield
33
-
34
-
35
- def _sqlite_url(path: Path) -> str:
36
- return URL.create(
37
- drivername="sqlite+aiosqlite",
38
- database=str(path.resolve()),
39
- ).render_as_string(hide_password=False)
40
-
41
-
42
- @pytest.mark.asyncio
43
- async def test_sqlite_null_optional_datetime_hydrates_as_none_after_reconnect() -> None:
44
- """Alembic-created DATETIME NULL must not become int(0) on a fresh connection."""
45
-
46
- class Client(Model):
47
- id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True, db_type="text")
48
- name: str
49
- archived_at: datetime | None = None
50
-
51
- with tempfile.TemporaryDirectory() as tmp:
52
- db_path = Path(tmp) / "app.db"
53
- uri = f"sqlite:{db_path}?mode=rwc"
54
-
55
- engine = create_async_engine(_sqlite_url(db_path), poolclass=pool.NullPool)
56
- async with engine.begin() as conn:
57
- await conn.run_sync(lambda sync_conn: get_metadata().create_all(sync_conn))
58
- await engine.dispose()
59
-
60
- await ferro.connect(uri, auto_migrate=False)
61
- await Client.create(name="Acme")
62
- ferro.reset_engine()
63
-
64
- await ferro.connect(uri, auto_migrate=False)
65
- row = (await Client.all())[0]
66
-
67
- with sqlite3.connect(db_path) as conn:
68
- raw = conn.execute("SELECT archived_at FROM client").fetchone()[0]
69
-
70
- assert raw is None
71
- assert row.archived_at is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes