ferro-orm 0.10.1__tar.gz → 0.10.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 (170) hide show
  1. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/CHANGELOG.md +18 -0
  2. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/Cargo.lock +3 -3
  3. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/Cargo.toml +1 -1
  4. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/PKG-INFO +1 -1
  5. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +2 -2
  6. ferro_orm-0.10.3/docs/solutions/issues/typed-where-null-panics-is-null.md +107 -0
  7. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/typed-null-binds.md +15 -4
  8. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/pyproject.toml +1 -1
  9. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/operations.rs +123 -0
  10. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/query.rs +139 -4
  11. ferro_orm-0.10.3/tests/test_sqlite_alembic_reconnect_hydration.py +272 -0
  12. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_typed_null_binds.py +64 -36
  13. ferro_orm-0.10.1/tests/test_sqlite_null_hydration.py +0 -71
  14. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/PERMISSIONS.md +0 -0
  17. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/PYPI_CHECKLIST.md +0 -0
  18. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/PYPI_SETUP.md +0 -0
  19. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/generated/wheels.generated.yml +0 -0
  20. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/pull_request_template.md +0 -0
  21. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/ci.yml +0 -0
  22. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/packaging-smoke.yml +0 -0
  23. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/publish-docs.yml +0 -0
  24. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/publish.yml +0 -0
  25. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/release.yml +0 -0
  26. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.gitignore +0 -0
  27. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.pre-commit-config.yaml +0 -0
  28. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.python-version +0 -0
  29. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/AGENTS.md +0 -0
  30. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/CONTRIBUTING.md +0 -0
  31. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/LICENSE +0 -0
  32. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/README.md +0 -0
  33. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/TEST_RESULTS.md +0 -0
  34. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/exceptions.md +0 -0
  35. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/fields.md +0 -0
  36. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/model.md +0 -0
  37. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/query.md +0 -0
  38. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/raw-sql.md +0 -0
  39. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/relationships.md +0 -0
  40. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/transactions.md +0 -0
  41. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/utilities.md +0 -0
  42. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  43. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  44. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  45. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  46. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/changelog.md +0 -0
  47. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/coming-soon.md +0 -0
  48. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/architecture.md +0 -0
  49. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/identity-map.md +0 -0
  50. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/performance.md +0 -0
  51. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/query-typing.md +0 -0
  52. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/type-safety.md +0 -0
  53. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/contributing.md +0 -0
  54. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/faq.md +0 -0
  55. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/getting-started/installation.md +0 -0
  56. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/getting-started/next-steps.md +0 -0
  57. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/getting-started/tutorial.md +0 -0
  58. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/backend.md +0 -0
  59. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/database.md +0 -0
  60. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/migrations.md +0 -0
  61. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/models-and-fields.md +0 -0
  62. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/mutations.md +0 -0
  63. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/queries.md +0 -0
  64. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/relationships.md +0 -0
  65. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/transactions.md +0 -0
  66. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/multiple-databases.md +0 -0
  67. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/pagination.md +0 -0
  68. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/soft-deletes.md +0 -0
  69. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/testing.md +0 -0
  70. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/timestamps.md +0 -0
  71. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/index.md +0 -0
  72. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/migration-sqlalchemy.md +0 -0
  73. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  74. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  75. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  76. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  77. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  78. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  79. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/README.md +0 -0
  80. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  81. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  82. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  83. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  84. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  85. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  86. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/foreign-key-index.md +0 -0
  87. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  88. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  89. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/stylesheets/extra.css +0 -0
  90. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/why-ferro.md +0 -0
  91. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/justfile +0 -0
  92. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/mkdocs.yml +0 -0
  93. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/scripts/demo_queries.py +0 -0
  94. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/backend.rs +0 -0
  95. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/connection.rs +0 -0
  96. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/__init__.py +0 -0
  97. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/_annotation_utils.py +0 -0
  98. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/_core.pyi +0 -0
  99. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/_shadow_fk_types.py +0 -0
  100. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/base.py +0 -0
  101. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/composite_indexes.py +0 -0
  102. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/composite_uniques.py +0 -0
  103. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/exceptions.py +0 -0
  104. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/fields.py +0 -0
  105. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/metaclass.py +0 -0
  106. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/migrations/__init__.py +0 -0
  107. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/migrations/alembic.py +0 -0
  108. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/models.py +0 -0
  109. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/py.typed +0 -0
  110. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/query/__init__.py +0 -0
  111. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/query/builder.py +0 -0
  112. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/query/nodes.py +0 -0
  113. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/raw.py +0 -0
  114. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/relations/__init__.py +0 -0
  115. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/relations/descriptors.py +0 -0
  116. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/schema_metadata.py +0 -0
  117. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/state.py +0 -0
  118. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/lib.rs +0 -0
  119. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/schema.rs +0 -0
  120. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/state.rs +0 -0
  121. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/__init__.py +0 -0
  122. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/conftest.py +0 -0
  123. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/db_backends.py +0 -0
  124. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_aggregation.py +0 -0
  125. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_autogenerate.py +0 -0
  126. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_bridge.py +0 -0
  127. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_db_type.py +0 -0
  128. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_nullability.py +0 -0
  129. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_type_mapping.py +0 -0
  130. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_auto_migrate.py +0 -0
  131. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_bulk_update.py +0 -0
  132. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_composite_index.py +0 -0
  133. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_composite_unique.py +0 -0
  134. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_connection.py +0 -0
  135. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_connection_redaction.py +0 -0
  136. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_constraints.py +0 -0
  137. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_cross_emitter_parity.py +0 -0
  138. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_crud.py +0 -0
  139. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_backends.py +0 -0
  140. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_cross_emitter_parity.py +0 -0
  141. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_integration.py +0 -0
  142. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_typing.py +0 -0
  143. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_validation.py +0 -0
  144. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_deletion.py +0 -0
  145. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_docs_examples.py +0 -0
  146. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_documentation_features.py +0 -0
  147. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_field_wrapper.py +0 -0
  148. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_helpers.py +0 -0
  149. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_hydration.py +0 -0
  150. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_metaclass_internals.py +0 -0
  151. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_metadata.py +0 -0
  152. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_models.py +0 -0
  153. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_named_connections_integration.py +0 -0
  154. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_one_to_one.py +0 -0
  155. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_query_builder.py +0 -0
  156. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_query_typing.py +0 -0
  157. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_raw_sql.py +0 -0
  158. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_refresh.py +0 -0
  159. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_relationship_engine.py +0 -0
  160. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema.py +0 -0
  161. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema_constraints.py +0 -0
  162. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema_db_type_metadata.py +0 -0
  163. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema_enum_annotations.py +0 -0
  164. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_shadow_fk_types.py +0 -0
  165. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_static_contracts.py +0 -0
  166. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_string_search.py +0 -0
  167. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_structural_types.py +0 -0
  168. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_temporal_types.py +0 -0
  169. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_transactions.py +0 -0
  170. {ferro_orm-0.10.1 → ferro_orm-0.10.3}/uv.lock +0 -0
@@ -1,6 +1,24 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.10.3 (2026-05-21)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **query**: Typed predicates `col == None` / `!= None` → IS NULL / IS NOT NULL
9
+ ([#62](https://github.com/syn54x/ferro-orm/pull/62),
10
+ [`fd4b53e`](https://github.com/syn54x/ferro-orm/commit/fd4b53e26e01f8cf41a9b73de7c29b6901dee786))
11
+
12
+
13
+ ## v0.10.2 (2026-05-19)
14
+
15
+ ### Bug Fixes
16
+
17
+ - Hydrate SQLite INTEGER-backed Decimal columns on reconnect
18
+ ([#59](https://github.com/syn54x/ferro-orm/pull/59),
19
+ [`6f13906`](https://github.com/syn54x/ferro-orm/commit/6f13906850300bc9e85c1274763c49bc5b318b5d))
20
+
21
+
4
22
  ## v0.10.1 (2026-05-19)
5
23
 
6
24
  ### Bug Fixes
@@ -247,9 +247,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
247
247
 
248
248
  [[package]]
249
249
  name = "either"
250
- version = "1.15.0"
250
+ version = "1.16.0"
251
251
  source = "registry+https://github.com/rust-lang/crates.io-index"
252
- checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
252
+ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
253
253
  dependencies = [
254
254
  "serde",
255
255
  ]
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.10.1"
297
+ version = "0.10.3"
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.3"
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.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'
@@ -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
@@ -0,0 +1,107 @@
1
+ ---
2
+ title: Typed WHERE filters with None panic or wrong SQL (IS NULL)
3
+ type: issue
4
+ tags: [gotcha, query, filter, is-null, bridge, ffi, rust, pyo3, sea-query, serde]
5
+ related_files:
6
+ - src/query.rs
7
+ - src/ferro/query/nodes.py
8
+ - tests/test_typed_null_binds.py
9
+ - docs/solutions/patterns/typed-null-binds.md
10
+ related_issues: [41, 61]
11
+ related_prs: [62]
12
+ captured: 2026-05-20
13
+ last_updated: 2026-05-20
14
+ ---
15
+
16
+ ## Problem
17
+
18
+ `Model.where(lambda t: t.col == None)` or `Model.col == None` / `!= None` used to
19
+ panic in Rust (`node_to_condition_for_backend`) or, if it did not panic, would
20
+ compile to `col = NULL`, which never matches rows in SQL.
21
+
22
+ ## Takeaway
23
+
24
+ Python `None` on the filter RHS becomes JSON `"value": null`, which serde
25
+ deserializes as `Option<serde_json::Value>::None` — not `Some(Value::Null)`.
26
+ For `==` / `!=`, emit `IS NULL` / `IS NOT NULL` in `node_to_condition_for_backend`;
27
+ never `unwrap()` `node.value` before checking for a null RHS. Bind typing for
28
+ other operators stays in the typed-null bind pipeline — see
29
+ `docs/solutions/patterns/typed-null-binds.md`.
30
+
31
+ ## Explanation
32
+
33
+ **Causal chain**
34
+
35
+ 1. `QueryNode.to_dict()` serializes Python `None` as JSON `null` on `value`.
36
+ 2. Rust `QueryNode { value: Option<serde_json::Value> }` deserializes absent/null
37
+ as `None`, not `Some(Null)`.
38
+ 3. Pre-fix code: `let val = node.value.as_ref().unwrap();` → panic across FFI
39
+ (AGENTS.md I-3).
40
+ 4. Even without panic, `col.eq(bind_null)` yields `col = NULL`, which is always
41
+ unknown/false in three-valued logic — filters return no rows.
42
+
43
+ **Fix (PR #62, issue #41)**
44
+
45
+ ```rust
46
+ let rhs_is_json_null = node.value.as_ref().map_or(true, serde_json::Value::is_null);
47
+
48
+ let expr: SimpleExpr = if rhs_is_json_null {
49
+ match node.operator.as_str() {
50
+ "==" => col.is_null(),
51
+ "!=" => col.is_not_null(),
52
+ // other ops: value_rhs_simple_expr_for_backend(..., &Value::Null, ...)
53
+ ...
54
+ }
55
+ } else {
56
+ let val = node.value.as_ref().unwrap();
57
+ // existing non-null paths
58
+ };
59
+ ```
60
+
61
+ **Discovery before fix (session history)**
62
+
63
+ During the April `refactor/typed-null-binds` work, the same wire shape was
64
+ identified and GitHub #41 was filed; `test_filter_by_none_does_not_reproduce_38`
65
+ stayed `xfail(strict=True)` until this fix landed on branch
66
+ `cursor/fix-typed-predicate-null-is-null-f3a4`.
67
+
68
+ **Tests**
69
+
70
+ | Layer | File | What it pins |
71
+ |-------|------|----------------|
72
+ | Integration | `tests/test_typed_null_binds.py` | `test_filter_by_none_does_not_reproduce_38` — FieldProxy `== None` / `!= None` |
73
+ | Integration | `tests/test_typed_null_binds.py` | `test_lambda_predicate_null_filter_datetime_and_json` — QueryProxy lambda on nullable datetime + JSON |
74
+ | Rust unit | `src/query.rs` | `json_null_deserializes_to_option_none_for_query_node_value` |
75
+ | Rust unit | `src/query.rs` | `where_rhs_none_emits_is_null_for_eq_sqlite` / `where_rhs_none_emits_is_not_null_for_ne_sqlite` |
76
+
77
+ ## How to recognize
78
+
79
+ - Crash or opaque unwind when filtering with `== None` / `!= None` on any column
80
+ type (including `datetime | None` and `list | None` / JSON).
81
+ - Grep finds `node.value.as_ref().unwrap()` in `node_to_condition_for_backend`
82
+ without a prior null-RHS guard.
83
+ - Generated SQL contains `= null` instead of `is null` for None filters.
84
+ - Distinct from issue #38 (typed **bind** `null::text` on INSERT) and #56 (SQLite
85
+ **fetch** NULL → `int(0)`); this is the **WHERE compile** path in `src/query.rs`.
86
+
87
+ ## Prevention
88
+
89
+ - Treat JSON `null` on optional Rust fields as “SQL null intent,” not as “missing
90
+ field” — use `map_or(true, Value::is_null)` (or equivalent) before `unwrap`.
91
+ - For every filter API that accepts Python `None`, add both FieldProxy and
92
+ QueryProxy lambda integration tests plus a Rust unit test on deserialized
93
+ `{"value": null}`.
94
+ - Keep the invariant in `typed-null-binds.md` § “IS NULL for typed `== None`”
95
+ when adding new schema-driven emitters.
96
+
97
+ ## Related
98
+
99
+ - Pattern: `docs/solutions/patterns/typed-null-binds.md` (bind layer + IS NULL rule)
100
+ - Issue [#41]: https://github.com/syn54x/ferro-orm/issues/41
101
+ - Issue [#61]: https://github.com/syn54x/ferro-orm/issues/61 (duplicate report)
102
+ - PR [#62]: https://github.com/syn54x/ferro-orm/pull/62
103
+ - Issue [#38]: typed-null binds on Postgres (orthogonal root cause)
104
+
105
+ [#38]: https://github.com/syn54x/ferro-orm/issues/38
106
+ [#41]: https://github.com/syn54x/ferro-orm/issues/41
107
+ [#61]: https://github.com/syn54x/ferro-orm/issues/61
@@ -7,10 +7,11 @@ 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
11
- related_issues: [38, 40, 56]
12
- related_prs: []
10
+ - tests/test_sqlite_alembic_reconnect_hydration.py
11
+ related_issues: [38, 40, 41, 56]
12
+ related_prs: [62]
13
13
  captured: 2026-04-29
14
+ last_updated: 2026-05-20
14
15
  ---
15
16
 
16
17
  ## Problem
@@ -72,6 +73,11 @@ digraph typed_null_flow {
72
73
  | Filter null pick | `typed_null_for_column` (called by ^) | `src/query.rs` |
73
74
  | M2M target IDs | `backend_column_value_expr` | `src/operations.rs`|
74
75
 
76
+ **`IS NULL` for typed `== None`:** JSON `null` on `QueryNode::value` deserializes
77
+ as `Option<serde_json::Value>::None` (serde), not `Some(Value::Null)`.
78
+ `node_to_condition_for_backend` maps `==` / `!=` with a null RHS to
79
+ `IS NULL` / `IS NOT NULL` — never `= NULL`, which is never true in SQL.
80
+
75
81
  These functions inspect column metadata (JSON type, format, `uuid_columns`
76
82
  introspection, `ts_cast` metadata) and emit one of the typed SeaQuery `None`
77
83
  variants:
@@ -112,7 +118,7 @@ typed `try_get`. Non-null integer `0` is unchanged; only SQL `NULL` is affected.
112
118
  | Fetch (read) | `materialize_engine_row` | `is_null()` before `decode_non_null_*`|
113
119
 
114
120
  Rust regression: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`.
115
- Integration: `tests/test_sqlite_null_hydration.py` (Alembic schema + reconnect).
121
+ Integration: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic schema + reconnect).
116
122
 
117
123
  ## Raw-SQL boundary (explicit exception)
118
124
 
@@ -193,7 +199,12 @@ Ferro itself.
193
199
  NULL → `int(0)` on SQLite (issue [#56]).
194
200
  - Issue [#38]: the original bug report.
195
201
  - Issue [#40]: temporal typed binds (deferred follow-up).
202
+ - Issue [#41]: filter `== None` panic / `IS NULL` compile path — debugging story in
203
+ `docs/solutions/issues/typed-where-null-panics-is-null.md`.
204
+ - PR [#62]: `fix(query): typed predicates col == None → IS NULL / IS NOT NULL`.
196
205
 
197
206
  [#38]: https://github.com/syn54x/ferro-orm/issues/38
198
207
  [#40]: https://github.com/syn54x/ferro-orm/issues/40
208
+ [#41]: https://github.com/syn54x/ferro-orm/issues/41
199
209
  [#56]: https://github.com/syn54x/ferro-orm/issues/56
210
+ [#62]: https://github.com/syn54x/ferro-orm/pull/62
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.10.1"
3
+ version = "0.10.3"
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
+ }
@@ -63,10 +63,75 @@ impl QueryDef {
63
63
  }
64
64
  } else {
65
65
  let col_name = node.column.as_ref().unwrap();
66
- let val = node.value.as_ref().unwrap();
67
66
  let col = Expr::col(Alias::new(col_name));
68
67
 
69
- let expr: SimpleExpr =
68
+ // Python `None` becomes JSON `null`, which serde deserializes as
69
+ // `Option<serde_json::Value>::None` (not `Some(Null)`). SQL `col = NULL`
70
+ // is never true — use `IS NULL` / `IS NOT NULL` for `== None` / `!= None`.
71
+ let rhs_is_json_null = node.value.as_ref().map_or(true, serde_json::Value::is_null);
72
+
73
+ let expr: SimpleExpr = if rhs_is_json_null {
74
+ match node.operator.as_str() {
75
+ "==" => col.is_null(),
76
+ "!=" => col.is_not_null(),
77
+ "<" => col.lt(self.value_rhs_simple_expr_for_backend(
78
+ col_name,
79
+ &Value::Null,
80
+ false,
81
+ backend,
82
+ )),
83
+ "<=" => col.lte(self.value_rhs_simple_expr_for_backend(
84
+ col_name,
85
+ &Value::Null,
86
+ false,
87
+ backend,
88
+ )),
89
+ ">" => col.gt(self.value_rhs_simple_expr_for_backend(
90
+ col_name,
91
+ &Value::Null,
92
+ false,
93
+ backend,
94
+ )),
95
+ ">=" => col.gte(self.value_rhs_simple_expr_for_backend(
96
+ col_name,
97
+ &Value::Null,
98
+ false,
99
+ backend,
100
+ )),
101
+ "IN" => {
102
+ let val = node.value.as_ref().unwrap_or(&Value::Null);
103
+ if let Some(vals) = val.as_array() {
104
+ let rhs: Vec<SimpleExpr> = vals
105
+ .iter()
106
+ .map(|v| {
107
+ self.value_rhs_simple_expr_for_backend(
108
+ col_name, v, false, backend,
109
+ )
110
+ })
111
+ .collect();
112
+ col.is_in(rhs)
113
+ } else {
114
+ col.eq(self
115
+ .value_rhs_simple_expr_for_backend(col_name, val, false, backend))
116
+ }
117
+ }
118
+ "LIKE" => {
119
+ let val = node.value.as_ref().unwrap_or(&Value::Null);
120
+ let pattern = match val {
121
+ Value::String(s) => s.clone(),
122
+ _ => val.to_string(),
123
+ };
124
+ col.like(pattern)
125
+ }
126
+ _ => col.eq(self.value_rhs_simple_expr_for_backend(
127
+ col_name,
128
+ &Value::Null,
129
+ false,
130
+ backend,
131
+ )),
132
+ }
133
+ } else {
134
+ let val = node.value.as_ref().unwrap();
70
135
  match node.operator.as_str() {
71
136
  "==" => col
72
137
  .eq(self.value_rhs_simple_expr_for_backend(col_name, val, false, backend)),
@@ -105,7 +170,8 @@ impl QueryDef {
105
170
  }
106
171
  _ => col
107
172
  .eq(self.value_rhs_simple_expr_for_backend(col_name, val, false, backend)),
108
- };
173
+ }
174
+ };
109
175
  Condition::all().add(expr)
110
176
  }
111
177
  }
@@ -360,7 +426,7 @@ pub(crate) fn property_schema_is_uuid(col_info: &Value) -> bool {
360
426
 
361
427
  #[cfg(test)]
362
428
  mod tests {
363
- use super::QueryDef;
429
+ use super::{QueryDef, QueryNode};
364
430
  use crate::backend::BackendKind;
365
431
  use sea_query::{Alias, PostgresQueryBuilder, Query, SqliteQueryBuilder, Value as SeaValue};
366
432
  use serde_json::json;
@@ -385,6 +451,75 @@ mod tests {
385
451
  values.0.into_iter().next().expect("one value")
386
452
  }
387
453
 
454
+ #[test]
455
+ fn json_null_deserializes_to_option_none_for_query_node_value() {
456
+ let node: QueryNode = serde_json::from_value(json!({
457
+ "is_compound": false,
458
+ "column": "count",
459
+ "operator": "==",
460
+ "value": null
461
+ }))
462
+ .unwrap();
463
+ assert!(node.value.is_none());
464
+ }
465
+
466
+ #[test]
467
+ fn where_rhs_none_emits_is_null_for_eq_sqlite() {
468
+ let node: QueryNode = serde_json::from_value(json!({
469
+ "is_compound": false,
470
+ "column": "attached_at",
471
+ "operator": "==",
472
+ "value": null
473
+ }))
474
+ .unwrap();
475
+ let q = QueryDef {
476
+ model_name: "Pending".to_string(),
477
+ where_clause: vec![node],
478
+ order_by: None,
479
+ limit: None,
480
+ offset: None,
481
+ m2m: None,
482
+ };
483
+ let mut select = Query::select();
484
+ select
485
+ .from(Alias::new("pending"))
486
+ .cond_where(q.to_condition_for_backend(BackendKind::Sqlite));
487
+ let sql = select.to_string(SqliteQueryBuilder).to_lowercase();
488
+ assert!(sql.contains("is null"), "expected IS NULL, got {sql}");
489
+ assert!(
490
+ !sql.contains("= null"),
491
+ "must not emit `= NULL` (always unknown in SQL): {sql}"
492
+ );
493
+ }
494
+
495
+ #[test]
496
+ fn where_rhs_none_emits_is_not_null_for_ne_sqlite() {
497
+ let node: QueryNode = serde_json::from_value(json!({
498
+ "is_compound": false,
499
+ "column": "payload",
500
+ "operator": "!=",
501
+ "value": null
502
+ }))
503
+ .unwrap();
504
+ let q = QueryDef {
505
+ model_name: "Pending".to_string(),
506
+ where_clause: vec![node],
507
+ order_by: None,
508
+ limit: None,
509
+ offset: None,
510
+ m2m: None,
511
+ };
512
+ let mut select = Query::select();
513
+ select
514
+ .from(Alias::new("pending"))
515
+ .cond_where(q.to_condition_for_backend(BackendKind::Sqlite));
516
+ let sql = select.to_string(SqliteQueryBuilder).to_lowercase();
517
+ assert!(
518
+ sql.contains("is not null"),
519
+ "expected IS NOT NULL, got {sql}"
520
+ );
521
+ }
522
+
388
523
  #[test]
389
524
  fn uuid_rhs_emits_typed_uuid_bind_on_postgres_no_cast() {
390
525
  let query_def = empty_query_def("Widget");