ferro-orm 0.10.0__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 (168) hide show
  1. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/CHANGELOG.md +24 -0
  2. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/Cargo.lock +1 -1
  3. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/Cargo.toml +1 -1
  4. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/PKG-INFO +1 -1
  5. ferro_orm-0.10.2/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +92 -0
  6. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/shadow-fk-columns.md +3 -1
  7. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/typed-null-binds.md +30 -2
  8. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/pyproject.toml +5 -1
  9. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/backend.rs +94 -15
  10. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/operations.rs +123 -0
  11. ferro_orm-0.10.2/tests/test_db_type_integration.py +383 -0
  12. ferro_orm-0.10.2/tests/test_sqlite_alembic_reconnect_hydration.py +272 -0
  13. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/uv.lock +21 -1
  14. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/PERMISSIONS.md +0 -0
  17. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/PYPI_CHECKLIST.md +0 -0
  18. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/PYPI_SETUP.md +0 -0
  19. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/generated/wheels.generated.yml +0 -0
  20. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/pull_request_template.md +0 -0
  21. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/ci.yml +0 -0
  22. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/packaging-smoke.yml +0 -0
  23. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/publish-docs.yml +0 -0
  24. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/publish.yml +0 -0
  25. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/release.yml +0 -0
  26. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.gitignore +0 -0
  27. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.pre-commit-config.yaml +0 -0
  28. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.python-version +0 -0
  29. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/AGENTS.md +0 -0
  30. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/CONTRIBUTING.md +0 -0
  31. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/LICENSE +0 -0
  32. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/README.md +0 -0
  33. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/TEST_RESULTS.md +0 -0
  34. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/exceptions.md +0 -0
  35. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/fields.md +0 -0
  36. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/model.md +0 -0
  37. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/query.md +0 -0
  38. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/raw-sql.md +0 -0
  39. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/relationships.md +0 -0
  40. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/transactions.md +0 -0
  41. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/utilities.md +0 -0
  42. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  43. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  44. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  45. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  46. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/changelog.md +0 -0
  47. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/coming-soon.md +0 -0
  48. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/architecture.md +0 -0
  49. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/identity-map.md +0 -0
  50. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/performance.md +0 -0
  51. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/query-typing.md +0 -0
  52. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/type-safety.md +0 -0
  53. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/contributing.md +0 -0
  54. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/faq.md +0 -0
  55. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/getting-started/installation.md +0 -0
  56. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/getting-started/next-steps.md +0 -0
  57. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/getting-started/tutorial.md +0 -0
  58. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/backend.md +0 -0
  59. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/database.md +0 -0
  60. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/migrations.md +0 -0
  61. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/models-and-fields.md +0 -0
  62. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/mutations.md +0 -0
  63. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/queries.md +0 -0
  64. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/relationships.md +0 -0
  65. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/transactions.md +0 -0
  66. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/multiple-databases.md +0 -0
  67. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/pagination.md +0 -0
  68. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/soft-deletes.md +0 -0
  69. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/testing.md +0 -0
  70. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/timestamps.md +0 -0
  71. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/index.md +0 -0
  72. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/migration-sqlalchemy.md +0 -0
  73. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  74. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  75. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  76. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  77. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  78. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  79. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/README.md +0 -0
  80. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  81. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  82. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  83. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  84. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  85. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  86. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
  87. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  88. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/stylesheets/extra.css +0 -0
  89. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/why-ferro.md +0 -0
  90. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/justfile +0 -0
  91. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/mkdocs.yml +0 -0
  92. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/scripts/demo_queries.py +0 -0
  93. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/connection.rs +0 -0
  94. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/__init__.py +0 -0
  95. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/_annotation_utils.py +0 -0
  96. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/_core.pyi +0 -0
  97. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/_shadow_fk_types.py +0 -0
  98. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/base.py +0 -0
  99. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/composite_indexes.py +0 -0
  100. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/composite_uniques.py +0 -0
  101. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/exceptions.py +0 -0
  102. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/fields.py +0 -0
  103. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/metaclass.py +0 -0
  104. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/migrations/__init__.py +0 -0
  105. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/migrations/alembic.py +0 -0
  106. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/models.py +0 -0
  107. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/py.typed +0 -0
  108. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/query/__init__.py +0 -0
  109. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/query/builder.py +0 -0
  110. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/query/nodes.py +0 -0
  111. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/raw.py +0 -0
  112. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/relations/__init__.py +0 -0
  113. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/relations/descriptors.py +0 -0
  114. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/schema_metadata.py +0 -0
  115. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/state.py +0 -0
  116. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/lib.rs +0 -0
  117. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/query.rs +0 -0
  118. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/schema.rs +0 -0
  119. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/state.rs +0 -0
  120. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/__init__.py +0 -0
  121. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/conftest.py +0 -0
  122. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/db_backends.py +0 -0
  123. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_aggregation.py +0 -0
  124. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_autogenerate.py +0 -0
  125. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_bridge.py +0 -0
  126. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_db_type.py +0 -0
  127. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_nullability.py +0 -0
  128. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_type_mapping.py +0 -0
  129. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_auto_migrate.py +0 -0
  130. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_bulk_update.py +0 -0
  131. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_composite_index.py +0 -0
  132. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_composite_unique.py +0 -0
  133. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_connection.py +0 -0
  134. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_connection_redaction.py +0 -0
  135. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_constraints.py +0 -0
  136. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_cross_emitter_parity.py +0 -0
  137. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_crud.py +0 -0
  138. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_backends.py +0 -0
  139. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
  140. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_type_typing.py +0 -0
  141. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_type_validation.py +0 -0
  142. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_deletion.py +0 -0
  143. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_docs_examples.py +0 -0
  144. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_documentation_features.py +0 -0
  145. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_field_wrapper.py +0 -0
  146. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_helpers.py +0 -0
  147. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_hydration.py +0 -0
  148. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_metaclass_internals.py +0 -0
  149. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_metadata.py +0 -0
  150. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_models.py +0 -0
  151. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_named_connections_integration.py +0 -0
  152. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_one_to_one.py +0 -0
  153. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_query_builder.py +0 -0
  154. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_query_typing.py +0 -0
  155. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_raw_sql.py +0 -0
  156. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_refresh.py +0 -0
  157. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_relationship_engine.py +0 -0
  158. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema.py +0 -0
  159. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema_constraints.py +0 -0
  160. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema_db_type_metadata.py +0 -0
  161. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema_enum_annotations.py +0 -0
  162. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_shadow_fk_types.py +0 -0
  163. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_static_contracts.py +0 -0
  164. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_string_search.py +0 -0
  165. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_structural_types.py +0 -0
  166. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_temporal_types.py +0 -0
  167. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_transactions.py +0 -0
  168. {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_typed_null_binds.py +0 -0
@@ -1,6 +1,30 @@
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
+
13
+ ## v0.10.1 (2026-05-19)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **sqlite**: Hydrate SQL NULL as None instead of int 0
18
+ ([#57](https://github.com/syn54x/ferro-orm/pull/57),
19
+ [`249c81f`](https://github.com/syn54x/ferro-orm/commit/249c81f4b37f117c3ca80f44a9682511154ab9ec))
20
+
21
+ ### Testing
22
+
23
+ - **schema**: Integration coverage for db_type / db_check
24
+ ([#55](https://github.com/syn54x/ferro-orm/pull/55),
25
+ [`b97d596`](https://github.com/syn54x/ferro-orm/commit/b97d59667d520c026d8a6fbb73ad1de7f71593b4))
26
+
27
+
4
28
  ## v0.10.0 (2026-05-18)
5
29
 
6
30
  ### Features
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.10.0"
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.0"
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.0
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'
@@ -0,0 +1,92 @@
1
+ ---
2
+ title: SQLite NULL columns hydrate as int 0 after reconnect (Alembic DATETIME)
3
+ type: issue
4
+ tags: [gotcha, sqlite, hydration, bridge, rust, sqlalchemy, alembic, datetime, ffi]
5
+ related_files:
6
+ - src/backend.rs
7
+ - src/operations.rs
8
+ - tests/test_sqlite_alembic_reconnect_hydration.py
9
+ related_issues: [56]
10
+ related_prs: [57]
11
+ captured: 2026-05-18
12
+ ---
13
+
14
+ ## Problem
15
+
16
+ Optional fields that are `NULL` in a SQLite database (for example
17
+ `archived_at: datetime | None`) sometimes appear as Python `int(0)` after a
18
+ **new connection** — `ferro.reset_engine()`, a new CLI process, or any fetch
19
+ that does not hit the identity map. The same row shows `None` on the connection
20
+ that just inserted it, and `sqlite3` confirms the column is `NULL`.
21
+
22
+ ## Takeaway
23
+
24
+ `materialize_engine_row` in `src/backend.rs` must call `ValueRef::is_null()` on
25
+ the raw column **before** typed `try_get` decoding. On SQLite, `try_get::<i64>`
26
+ on SQL `NULL` often succeeds with `0` for INTEGER/NUMERIC-affinity columns
27
+ (including Alembic `DateTime()` → `DATETIME`). That is not limited to datetimes:
28
+ `INTEGER`, `REAL`, `TEXT`, and `NUMERIC` NULLs were all misread as `0` before
29
+ the fix.
30
+
31
+ ## Explanation
32
+
33
+ **Causal chain**
34
+
35
+ 1. Alembic/SQLAlchemy creates nullable `archived_at` as `DATETIME` (numeric
36
+ affinity on SQLite).
37
+ 2. Insert leaves the column SQL `NULL`.
38
+ 3. Fetch uses `materialize_engine_row`, which tried `try_get::<i64>` first.
39
+ 4. sqlx returns `Ok(0)` for NULL on those affinities instead of an error.
40
+ 5. `engine_value_to_rust_value` maps `EngineValue::I64` to `RustValue::BigInt`
41
+ (date-time `format` only applies to `EngineValue::String`).
42
+ 6. Python sees `0`, so `archived_at is None` filters fail.
43
+
44
+ **Why same-session `create()` looked fine**
45
+
46
+ The identity map returns the in-memory instance from insert without re-reading
47
+ the row from SQLite.
48
+
49
+ **Fix (PR #57)**
50
+
51
+ ```rust
52
+ let value = match row.try_get_raw(ordinal) {
53
+ Ok(raw) if raw.is_null() => EngineValue::Null,
54
+ Ok(_) | Err(_) => decode_non_null_engine_value(row, ordinal),
55
+ };
56
+ ```
57
+
58
+ Legitimate integer `0` is unchanged: SQL `NULL` is detected via `is_null()`, not
59
+ by treating `0` as missing.
60
+
61
+ **Tests**
62
+
63
+ - Rust: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`,
64
+ `engine_handle_fetches_sqlite_non_null_zero_integer` in `src/backend.rs`.
65
+ - Python: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic `create_all` + reconnect).
66
+ Requires `aiosqlite` and `greenlet` in `ci-test` / `dev` so CI does not skip.
67
+
68
+ ## How to recognize
69
+
70
+ - Bug only on **SQLite**, often with **Alembic-created** schema (`auto_migrate=False`).
71
+ - `field is None` checks fail while raw SQL shows `NULL`.
72
+ - Same process right after `create()` is correct; new connection or `reset_engine()` is wrong.
73
+ - Affected value is `0` (int), not a datetime string or `AttributeError`.
74
+ - Raw `ferro.raw.fetch_all` shows the same `0` — failure is in row materialization, not Pydantic coercion alone.
75
+
76
+ ## Prevention
77
+
78
+ - Any change to `materialize_engine_row` or `EngineValue` decoding: add a Rust
79
+ test that inserts `DEFAULT VALUES` into a nullable column and asserts
80
+ `EngineValue::Null`.
81
+ - Do not “fix” optional datetimes in Python by treating `0` as unset — fix NULL
82
+ detection at the bridge.
83
+ - Related but distinct: typed **bind** NULLs for Postgres are documented in
84
+ `docs/solutions/patterns/typed-null-binds.md` (`NullKind`, insert/update paths).
85
+ Fetch-time NULL handling is separate; both layers need correct typing.
86
+
87
+ ## Related
88
+
89
+ - GitHub issue: https://github.com/syn54x/ferro-orm/issues/56
90
+ - PR: https://github.com/syn54x/ferro-orm/pull/57
91
+ - `docs/solutions/patterns/typed-null-binds.md` — NULL on the **write** path
92
+ - `docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md` — other hydration footguns
@@ -5,7 +5,9 @@ tags: [convention, schema, relationships, pydantic]
5
5
  related_files:
6
6
  - src/ferro/base.py
7
7
  - src/ferro/schema_metadata.py
8
- - src/ferro/relations.py
8
+ - src/ferro/metaclass.py
9
+ - src/ferro/_shadow_fk_types.py
10
+ - src/ferro/relations/__init__.py
9
11
  related_issues: [32]
10
12
  related_prs: [36]
11
13
  captured: 2026-04-28
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  title: Typed-null binds at the SQLx boundary
3
3
  type: pattern
4
- tags: [convention, invariant, bridge, ffi, rust, sea-query, sqlx, postgres]
4
+ tags: [convention, invariant, bridge, ffi, rust, sea-query, sqlx, postgres, sqlite]
5
5
  related_files:
6
6
  - src/backend.rs
7
7
  - src/operations.rs
8
8
  - src/query.rs
9
9
  - tests/test_typed_null_binds.py
10
- related_issues: [38, 40]
10
+ - tests/test_sqlite_alembic_reconnect_hydration.py
11
+ related_issues: [38, 40, 56]
11
12
  related_prs: []
12
13
  captured: 2026-04-29
13
14
  ---
@@ -92,6 +93,27 @@ are deferred (see plan §3 Scope Boundaries).
92
93
  \*\* Temporal types continue to use `cast_as` until issue [#40] picks a
93
94
  datetime crate (`chrono` vs `time`).
94
95
 
96
+ ## Fetch-time row materialization (read path)
97
+
98
+ Typed binds fix **outbound** NULL parameters. A separate failure mode is
99
+ **inbound** row decoding in `materialize_engine_row` (`src/backend.rs`).
100
+
101
+ On SQLite, `row.try_get::<i64>` on SQL `NULL` often returns `Ok(0)` for
102
+ INTEGER/NUMERIC-affinity columns (including Alembic `DateTime()` → `DATETIME`)
103
+ if you decode before checking nullness. Ferro then hydrates Python `int(0)`
104
+ instead of `None` — see issue [#56].
105
+
106
+ **Rule:** call `row.try_get_raw(ordinal)` and test `raw.is_null()` before any
107
+ typed `try_get`. Non-null integer `0` is unchanged; only SQL `NULL` is affected.
108
+
109
+ | Layer | Function / path | NULL concern |
110
+ |------------------|------------------------------|---------------------------------------|
111
+ | Bind (write) | `bind_engine_value` | `NullKind` → `Option::<T>::None` |
112
+ | Fetch (read) | `materialize_engine_row` | `is_null()` before `decode_non_null_*`|
113
+
114
+ Rust regression: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`.
115
+ Integration: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic schema + reconnect).
116
+
95
117
  ## Raw-SQL boundary (explicit exception)
96
118
 
97
119
  The raw-SQL bind path (`src/operations.rs::python_to_engine_bind_value`,
@@ -120,6 +142,9 @@ Ferro itself.
120
142
  to `Null(Untyped)` -- non-null data goes out as a text-typed null, and
121
143
  Postgres reports `expression is of type text` rather than the actual
122
144
  type mismatch. Always pair `Some` and `None` arms when adding a type.
145
+ - On SQLite, optional fields are `NULL` in the DB but Python sees `int(0)`
146
+ after reconnect — check `materialize_engine_row`, not bind typing alone
147
+ ([#56]).
123
148
 
124
149
  ## Recipe: adding a new schema-driven emitter
125
150
 
@@ -164,8 +189,11 @@ Ferro itself.
164
189
  - AGENTS.md I-3: no `unwrap()` across the FFI boundary.
165
190
  - `docs/plans/2026-04-29-001-typed-null-binds-plan.md`: the implementation
166
191
  plan with the unit-by-unit breakdown.
192
+ - `docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md`: fetch-time
193
+ NULL → `int(0)` on SQLite (issue [#56]).
167
194
  - Issue [#38]: the original bug report.
168
195
  - Issue [#40]: temporal typed binds (deferred follow-up).
169
196
 
170
197
  [#38]: https://github.com/syn54x/ferro-orm/issues/38
171
198
  [#40]: https://github.com/syn54x/ferro-orm/issues/40
199
+ [#56]: https://github.com/syn54x/ferro-orm/issues/56
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.10.0"
3
+ version = "0.10.2"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -43,6 +43,8 @@ ci-test = [
43
43
  "pytest-cov>=7.0.0",
44
44
  "pytest-examples>=0.0.18",
45
45
  "pytest-postgresql>=8.0.0",
46
+ "aiosqlite>=0.22.1",
47
+ "greenlet>=3.3.1",
46
48
  ]
47
49
  docs = [
48
50
  "mkdocs-material>=9.5.0",
@@ -74,6 +76,8 @@ dev = [
74
76
  "psycopg[binary]>=3.3.3",
75
77
  "pytest-postgresql>=8.0.0",
76
78
  "pytest-xdist>=3.8.0",
79
+ "aiosqlite>=0.22.1",
80
+ "greenlet>=3.3.1",
77
81
  ]
78
82
 
79
83
  [tool.pytest.ini_options]
@@ -1,6 +1,6 @@
1
1
  use sqlx::ColumnIndex;
2
2
  use sqlx::pool::PoolConnection;
3
- use sqlx::{Column, PgPool, Postgres, Row, Sqlite, SqlitePool};
3
+ use sqlx::{Column, PgPool, Postgres, Row, Sqlite, SqlitePool, ValueRef};
4
4
  use std::fmt;
5
5
  use std::sync::Arc;
6
6
 
@@ -360,20 +360,13 @@ where
360
360
  .iter()
361
361
  .map(|column| {
362
362
  let name = column.name().to_string();
363
- let value = if let Ok(value) = row.try_get::<i64, _>(column.ordinal()) {
364
- EngineValue::I64(value)
365
- } else if let Ok(value) = row.try_get::<i32, _>(column.ordinal()) {
366
- EngineValue::I64(i64::from(value))
367
- } else if let Ok(value) = row.try_get::<f64, _>(column.ordinal()) {
368
- EngineValue::F64(value)
369
- } else if let Ok(value) = row.try_get::<String, _>(column.ordinal()) {
370
- EngineValue::String(value)
371
- } else if let Ok(value) = row.try_get::<Vec<u8>, _>(column.ordinal()) {
372
- EngineValue::Bytes(value)
373
- } else if let Ok(value) = row.try_get::<bool, _>(column.ordinal()) {
374
- EngineValue::Bool(value)
375
- } else {
376
- EngineValue::Null
363
+ let ordinal = column.ordinal();
364
+ // SQLite (and some drivers) let `try_get::<i64>` succeed with 0 on SQL
365
+ // NULL when the column has INTEGER/NUMERIC affinity (including Alembic
366
+ // `DATETIME`). Always consult the raw value before typed decode.
367
+ let value = match row.try_get_raw(ordinal) {
368
+ Ok(raw) if raw.is_null() => EngineValue::Null,
369
+ Ok(_) | Err(_) => decode_non_null_engine_value(row, ordinal),
377
370
  };
378
371
  (name, value)
379
372
  })
@@ -382,6 +375,34 @@ where
382
375
  EngineRow { values }
383
376
  }
384
377
 
378
+ fn decode_non_null_engine_value<R>(row: &R, ordinal: usize) -> EngineValue
379
+ where
380
+ R: Row,
381
+ for<'r> i32: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
382
+ for<'r> i64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
383
+ for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
384
+ for<'r> Vec<u8>: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
385
+ for<'r> String: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
386
+ for<'r> bool: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
387
+ usize: ColumnIndex<R>,
388
+ {
389
+ if let Ok(value) = row.try_get::<i64, _>(ordinal) {
390
+ EngineValue::I64(value)
391
+ } else if let Ok(value) = row.try_get::<i32, _>(ordinal) {
392
+ EngineValue::I64(i64::from(value))
393
+ } else if let Ok(value) = row.try_get::<f64, _>(ordinal) {
394
+ EngineValue::F64(value)
395
+ } else if let Ok(value) = row.try_get::<String, _>(ordinal) {
396
+ EngineValue::String(value)
397
+ } else if let Ok(value) = row.try_get::<Vec<u8>, _>(ordinal) {
398
+ EngineValue::Bytes(value)
399
+ } else if let Ok(value) = row.try_get::<bool, _>(ordinal) {
400
+ EngineValue::Bool(value)
401
+ } else {
402
+ EngineValue::Null
403
+ }
404
+ }
405
+
385
406
  fn bind_engine_value<'q, DB>(
386
407
  query: sqlx::query::Query<'q, DB, <DB as sqlx::Database>::Arguments<'q>>,
387
408
  value: &'q EngineBindValue,
@@ -568,6 +589,64 @@ mod tests {
568
589
  assert_eq!(inserted, 1);
569
590
  }
570
591
 
592
+ #[tokio::test]
593
+ async fn engine_handle_fetches_sqlite_null_columns_as_null_not_zero() {
594
+ let pool = SqlitePoolOptions::new()
595
+ .max_connections(1)
596
+ .connect("sqlite::memory:")
597
+ .await
598
+ .unwrap();
599
+ let engine = EngineHandle::new_sqlite(pool);
600
+
601
+ for (table, ddl) in [
602
+ ("null_int", "CREATE TABLE null_int (v INTEGER)"),
603
+ ("null_real", "CREATE TABLE null_real (v REAL)"),
604
+ ("null_text", "CREATE TABLE null_text (v TEXT)"),
605
+ ("null_datetime", "CREATE TABLE null_datetime (v DATETIME)"),
606
+ ] {
607
+ engine.execute_sql(ddl).await.unwrap();
608
+ engine
609
+ .execute_sql(&format!("INSERT INTO {table} DEFAULT VALUES"))
610
+ .await
611
+ .unwrap();
612
+ let rows = engine
613
+ .fetch_all_sql_with_binds(&format!("SELECT v FROM {table}"), &[])
614
+ .await
615
+ .unwrap();
616
+ assert_eq!(
617
+ rows[0].values[0].1,
618
+ EngineValue::Null,
619
+ "SQL NULL in {table} must not decode as integer zero"
620
+ );
621
+ }
622
+ }
623
+
624
+ #[tokio::test]
625
+ async fn engine_handle_fetches_sqlite_non_null_zero_integer() {
626
+ let pool = SqlitePoolOptions::new()
627
+ .max_connections(1)
628
+ .connect("sqlite::memory:")
629
+ .await
630
+ .unwrap();
631
+ let engine = EngineHandle::new_sqlite(pool);
632
+
633
+ engine
634
+ .execute_sql("CREATE TABLE zero_int (v INTEGER NOT NULL)")
635
+ .await
636
+ .unwrap();
637
+ engine
638
+ .execute_sql("INSERT INTO zero_int (v) VALUES (0)")
639
+ .await
640
+ .unwrap();
641
+
642
+ let rows = engine
643
+ .fetch_all_sql_with_binds("SELECT v FROM zero_int", &[])
644
+ .await
645
+ .unwrap();
646
+
647
+ assert_eq!(rows[0].values[0].1, EngineValue::I64(0));
648
+ }
649
+
571
650
  #[tokio::test]
572
651
  async fn engine_handle_fetches_sqlite_rows_with_bound_values() {
573
652
  let pool = SqlitePoolOptions::new()
@@ -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
+ }