ferro-orm 0.10.2__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 (169) hide show
  1. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/CHANGELOG.md +9 -0
  2. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/Cargo.lock +3 -3
  3. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/Cargo.toml +1 -1
  4. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/PKG-INFO +1 -1
  5. ferro_orm-0.10.3/docs/solutions/issues/typed-where-null-panics-is-null.md +107 -0
  6. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/typed-null-binds.md +13 -2
  7. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/pyproject.toml +1 -1
  8. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/query.rs +139 -4
  9. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_typed_null_binds.py +64 -36
  10. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  11. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  12. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/PERMISSIONS.md +0 -0
  13. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/PYPI_CHECKLIST.md +0 -0
  14. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/PYPI_SETUP.md +0 -0
  15. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/generated/wheels.generated.yml +0 -0
  16. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/pull_request_template.md +0 -0
  17. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/ci.yml +0 -0
  18. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/packaging-smoke.yml +0 -0
  19. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/publish-docs.yml +0 -0
  20. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/publish.yml +0 -0
  21. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/release.yml +0 -0
  22. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.gitignore +0 -0
  23. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.pre-commit-config.yaml +0 -0
  24. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.python-version +0 -0
  25. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/AGENTS.md +0 -0
  26. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/CONTRIBUTING.md +0 -0
  27. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/LICENSE +0 -0
  28. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/README.md +0 -0
  29. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/TEST_RESULTS.md +0 -0
  30. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/exceptions.md +0 -0
  31. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/fields.md +0 -0
  32. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/model.md +0 -0
  33. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/query.md +0 -0
  34. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/raw-sql.md +0 -0
  35. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/relationships.md +0 -0
  36. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/transactions.md +0 -0
  37. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/utilities.md +0 -0
  38. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  39. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  40. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  41. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  42. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/changelog.md +0 -0
  43. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/coming-soon.md +0 -0
  44. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/architecture.md +0 -0
  45. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/identity-map.md +0 -0
  46. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/performance.md +0 -0
  47. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/query-typing.md +0 -0
  48. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/type-safety.md +0 -0
  49. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/contributing.md +0 -0
  50. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/faq.md +0 -0
  51. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/getting-started/installation.md +0 -0
  52. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/getting-started/next-steps.md +0 -0
  53. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/getting-started/tutorial.md +0 -0
  54. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/backend.md +0 -0
  55. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/database.md +0 -0
  56. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/migrations.md +0 -0
  57. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/models-and-fields.md +0 -0
  58. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/mutations.md +0 -0
  59. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/queries.md +0 -0
  60. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/relationships.md +0 -0
  61. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/transactions.md +0 -0
  62. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/multiple-databases.md +0 -0
  63. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/pagination.md +0 -0
  64. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/soft-deletes.md +0 -0
  65. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/testing.md +0 -0
  66. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/timestamps.md +0 -0
  67. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/index.md +0 -0
  68. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/migration-sqlalchemy.md +0 -0
  69. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  70. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  71. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  72. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  73. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  74. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  75. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/README.md +0 -0
  76. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  77. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  78. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  79. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  80. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  81. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  82. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  83. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/foreign-key-index.md +0 -0
  84. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  85. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  86. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/stylesheets/extra.css +0 -0
  87. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/why-ferro.md +0 -0
  88. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/justfile +0 -0
  89. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/mkdocs.yml +0 -0
  90. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/scripts/demo_queries.py +0 -0
  91. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/backend.rs +0 -0
  92. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/connection.rs +0 -0
  93. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/__init__.py +0 -0
  94. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/_annotation_utils.py +0 -0
  95. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/_core.pyi +0 -0
  96. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/_shadow_fk_types.py +0 -0
  97. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/base.py +0 -0
  98. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/composite_indexes.py +0 -0
  99. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/composite_uniques.py +0 -0
  100. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/exceptions.py +0 -0
  101. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/fields.py +0 -0
  102. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/metaclass.py +0 -0
  103. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/migrations/__init__.py +0 -0
  104. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/migrations/alembic.py +0 -0
  105. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/models.py +0 -0
  106. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/py.typed +0 -0
  107. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/query/__init__.py +0 -0
  108. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/query/builder.py +0 -0
  109. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/query/nodes.py +0 -0
  110. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/raw.py +0 -0
  111. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/relations/__init__.py +0 -0
  112. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/relations/descriptors.py +0 -0
  113. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/schema_metadata.py +0 -0
  114. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/state.py +0 -0
  115. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/lib.rs +0 -0
  116. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/operations.rs +0 -0
  117. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/schema.rs +0 -0
  118. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/state.rs +0 -0
  119. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/__init__.py +0 -0
  120. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/conftest.py +0 -0
  121. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/db_backends.py +0 -0
  122. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_aggregation.py +0 -0
  123. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_autogenerate.py +0 -0
  124. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_bridge.py +0 -0
  125. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_db_type.py +0 -0
  126. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_nullability.py +0 -0
  127. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_type_mapping.py +0 -0
  128. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_auto_migrate.py +0 -0
  129. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_bulk_update.py +0 -0
  130. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_composite_index.py +0 -0
  131. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_composite_unique.py +0 -0
  132. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_connection.py +0 -0
  133. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_connection_redaction.py +0 -0
  134. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_constraints.py +0 -0
  135. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_cross_emitter_parity.py +0 -0
  136. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_crud.py +0 -0
  137. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_backends.py +0 -0
  138. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_cross_emitter_parity.py +0 -0
  139. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_integration.py +0 -0
  140. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_typing.py +0 -0
  141. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_validation.py +0 -0
  142. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_deletion.py +0 -0
  143. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_docs_examples.py +0 -0
  144. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_documentation_features.py +0 -0
  145. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_field_wrapper.py +0 -0
  146. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_helpers.py +0 -0
  147. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_hydration.py +0 -0
  148. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_metaclass_internals.py +0 -0
  149. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_metadata.py +0 -0
  150. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_models.py +0 -0
  151. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_named_connections_integration.py +0 -0
  152. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_one_to_one.py +0 -0
  153. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_query_builder.py +0 -0
  154. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_query_typing.py +0 -0
  155. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_raw_sql.py +0 -0
  156. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_refresh.py +0 -0
  157. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_relationship_engine.py +0 -0
  158. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema.py +0 -0
  159. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema_constraints.py +0 -0
  160. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema_db_type_metadata.py +0 -0
  161. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema_enum_annotations.py +0 -0
  162. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_shadow_fk_types.py +0 -0
  163. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  164. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_static_contracts.py +0 -0
  165. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_string_search.py +0 -0
  166. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_structural_types.py +0 -0
  167. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_temporal_types.py +0 -0
  168. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_transactions.py +0 -0
  169. {ferro_orm-0.10.2 → ferro_orm-0.10.3}/uv.lock +0 -0
@@ -1,6 +1,15 @@
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
+
4
13
  ## v0.10.2 (2026-05-19)
5
14
 
6
15
  ### 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.2"
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.2"
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.2
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'
@@ -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
@@ -8,9 +8,10 @@ related_files:
8
8
  - src/query.rs
9
9
  - tests/test_typed_null_binds.py
10
10
  - tests/test_sqlite_alembic_reconnect_hydration.py
11
- related_issues: [38, 40, 56]
12
- related_prs: []
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:
@@ -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.2"
3
+ version = "0.10.3"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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");
@@ -6,29 +6,22 @@ The original bug was a PostgreSQL-only "null is text" failure. Postgres rejects
6
6
  and never reproduced #38; many of the round-trip assertions here are
7
7
  ``postgres_only`` because that's where the failure shape lives.
8
8
 
9
- Two known pre-existing bugs are *out of scope* for this refactor and limit
10
- what the matrix can assert. When either is fixed, drop the corresponding
11
- ``xfail``/``postgres_only`` marker on the test below.
12
-
13
- 1. `#41 <https://github.com/syn54x/ferro-orm/issues/41>`_ --
14
- ``Model.where(Model.col == None)`` panics in the Rust query builder
15
- (``query.rs::node_to_condition_for_backend`` unwraps ``node.value``).
16
- Backend-agnostic: surfaces on both SQLite and Postgres before any SQL
17
- is generated, so ``test_filter_by_none_does_not_reproduce_38`` is
18
- ``xfail(strict=True)`` until #41 closes -- the strict marker means the
19
- test will XPASS-as-failure the moment #41 is fixed, prompting us to
20
- drop the marker.
21
- 2. `#42 <https://github.com/syn54x/ferro-orm/issues/42>`_ --
22
- ``UPDATE col = NULL`` on SQLite reads back as ``0`` (or the type's zero
23
- value) due to a hydration issue in ``materialize_engine_row``. SQLite-
24
- specific, so ``test_update_to_none_for_each_type`` is ``postgres_only``.
9
+ One known pre-existing bug is *out of scope* for this refactor and limits
10
+ what the matrix can assert. When it is fixed, drop the corresponding
11
+ ``postgres_only`` marker on the test below.
12
+
13
+ - `#42 <https://github.com/syn54x/ferro-orm/issues/42>`_ --
14
+ ``UPDATE col = NULL`` on SQLite reads back as ``0`` (or the type's zero
15
+ value) due to a hydration issue in ``materialize_engine_row``. SQLite-
16
+ specific, so ``test_update_to_none_for_each_type`` is ``postgres_only``.
25
17
 
26
18
  See ``docs/plans/2026-04-29-001-typed-null-binds-plan.md`` for context.
27
19
  """
28
20
 
29
21
  import uuid
22
+ from datetime import UTC, datetime
30
23
  from decimal import Decimal
31
- from typing import Annotated
24
+ from typing import Annotated, Any
32
25
 
33
26
  import pytest
34
27
  from pydantic import Field
@@ -284,22 +277,12 @@ async def test_update_to_none_executes_without_error(db_url):
284
277
 
285
278
 
286
279
  @pytest.mark.asyncio
287
- @pytest.mark.xfail(
288
- strict=True,
289
- reason=(
290
- "Blocked by #41: filter `col == None` panics in "
291
- "node_to_condition_for_backend (Option::unwrap on node.value) "
292
- "before any SQL reaches the backend. Strict so we get an XPASS "
293
- "signal the moment #41 is fixed, then drop this marker."
294
- ),
295
- )
280
+ @pytest.mark.backend_matrix
296
281
  async def test_filter_by_none_does_not_reproduce_38(db_url):
297
- """Query filter ``WHERE col == None`` on a nullable integer column must
298
- not fail with a Postgres OID type error.
282
+ """Query filter ``col == None`` compiles to ``IS NULL`` and matches rows.
299
283
 
300
- Backend-agnostic: the panic from #41 short-circuits this test on every
301
- backend, not just SQLite. Confirmed reproduced on Postgres while running
302
- the full matrix during the typed-null-binds refactor."""
284
+ Regression for #41 (Rust panic on JSON null RHS). Also ensures the
285
+ typed-null bind path does not regress #38 on Postgres."""
303
286
 
304
287
  class Filterable(Model):
305
288
  id: Annotated[int | None, FerroField(primary_key=True)] = None
@@ -307,12 +290,57 @@ async def test_filter_by_none_does_not_reproduce_38(db_url):
307
290
 
308
291
  await connect(db_url, auto_migrate=True)
309
292
 
310
- await Filterable.create(count=1)
311
- await Filterable.create() # count = None
293
+ with_value = await Filterable.create(count=1)
294
+ null_row = await Filterable.create() # count = None
295
+
296
+ matched_null = await Filterable.where(Filterable.count == None).all() # noqa: E711
297
+ assert len(matched_null) == 1
298
+ assert matched_null[0].id == null_row.id
299
+
300
+ matched_non_null = await Filterable.where(Filterable.count != None).all() # noqa: E711
301
+ assert len(matched_non_null) == 1
302
+ assert matched_non_null[0].id == with_value.id
303
+
304
+
305
+ @pytest.mark.asyncio
306
+ @pytest.mark.backend_matrix
307
+ async def test_lambda_predicate_null_filter_datetime_and_json(db_url):
308
+ """Lambda ``where`` with ``== None`` / ``!= None`` on nullable datetime and JSON.
309
+
310
+ Regression for #41 using the API shape from the bug report (``QueryProxy``
311
+ + JSON ``null`` RHS), not only ``FieldProxy`` comparisons."""
312
+
313
+ class Pending(Model):
314
+ id: Annotated[int | None, FerroField(primary_key=True)] = None
315
+ name: str
316
+ attached_at: datetime | None = None
317
+ payload: list[Any] | None = None
318
+
319
+ await connect(db_url, auto_migrate=True)
320
+
321
+ null_dt = await Pending.create(name="null-dt", attached_at=None, payload=[{"x": 1}])
322
+ null_json = await Pending.create(name="null-json", attached_at=datetime.now(UTC))
323
+ with_values = await Pending.create(
324
+ name="set",
325
+ attached_at=datetime.now(UTC),
326
+ payload=[{"x": 2}],
327
+ )
328
+
329
+ matched_dt_null = await Pending.where(lambda t: t.attached_at == None).all() # noqa: E711
330
+ assert len(matched_dt_null) == 1
331
+ assert matched_dt_null[0].id == null_dt.id
332
+
333
+ matched_json_null = await Pending.where(lambda t: t.payload == None).all() # noqa: E711
334
+ assert len(matched_json_null) == 1
335
+ assert matched_json_null[0].id == null_json.id
336
+
337
+ matched_dt_set = await Pending.where(lambda t: t.attached_at != None).all() # noqa: E711
338
+ assert len(matched_dt_set) == 2
339
+ assert {r.id for r in matched_dt_set} == {null_json.id, with_values.id}
312
340
 
313
- # The assertion is "no Postgres OID error", not match counts.
314
- matched = await Filterable.where(Filterable.count == None).all() # noqa: E711
315
- assert isinstance(matched, list)
341
+ matched_json_set = await Pending.where(lambda t: t.payload != None).all() # noqa: E711
342
+ assert len(matched_json_set) == 2
343
+ assert {r.id for r in matched_json_set} == {null_dt.id, with_values.id}
316
344
 
317
345
 
318
346
  @pytest.mark.asyncio
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