ferro-orm 0.10.2__tar.gz → 0.10.4__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.2 → ferro_orm-0.10.4}/CHANGELOG.md +18 -0
  2. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/Cargo.lock +19 -19
  3. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/Cargo.toml +1 -1
  4. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/PKG-INFO +1 -1
  5. ferro_orm-0.10.4/docs/solutions/issues/typed-where-null-panics-is-null.md +107 -0
  6. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/patterns/typed-null-binds.md +13 -2
  7. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/pyproject.toml +1 -1
  8. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/lib.rs +1 -0
  9. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/operations.rs +16 -37
  10. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/query.rs +201 -4
  11. ferro_orm-0.10.4/src/schema_bind.rs +49 -0
  12. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_structural_types.py +39 -0
  13. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_typed_null_binds.py +64 -36
  14. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/PERMISSIONS.md +0 -0
  17. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/PYPI_CHECKLIST.md +0 -0
  18. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/PYPI_SETUP.md +0 -0
  19. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/generated/wheels.generated.yml +0 -0
  20. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/pull_request_template.md +0 -0
  21. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/workflows/ci.yml +0 -0
  22. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/workflows/packaging-smoke.yml +0 -0
  23. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/workflows/publish-docs.yml +0 -0
  24. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/workflows/publish.yml +0 -0
  25. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.github/workflows/release.yml +0 -0
  26. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.gitignore +0 -0
  27. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.pre-commit-config.yaml +0 -0
  28. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/.python-version +0 -0
  29. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/AGENTS.md +0 -0
  30. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/CONTRIBUTING.md +0 -0
  31. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/LICENSE +0 -0
  32. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/README.md +0 -0
  33. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/TEST_RESULTS.md +0 -0
  34. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/exceptions.md +0 -0
  35. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/fields.md +0 -0
  36. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/model.md +0 -0
  37. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/query.md +0 -0
  38. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/raw-sql.md +0 -0
  39. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/relationships.md +0 -0
  40. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/transactions.md +0 -0
  41. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/api/utilities.md +0 -0
  42. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  43. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  44. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  45. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  46. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/changelog.md +0 -0
  47. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/coming-soon.md +0 -0
  48. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/concepts/architecture.md +0 -0
  49. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/concepts/identity-map.md +0 -0
  50. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/concepts/performance.md +0 -0
  51. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/concepts/query-typing.md +0 -0
  52. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/concepts/type-safety.md +0 -0
  53. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/contributing.md +0 -0
  54. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/faq.md +0 -0
  55. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/getting-started/installation.md +0 -0
  56. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/getting-started/next-steps.md +0 -0
  57. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/getting-started/tutorial.md +0 -0
  58. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/backend.md +0 -0
  59. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/database.md +0 -0
  60. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/migrations.md +0 -0
  61. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/models-and-fields.md +0 -0
  62. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/mutations.md +0 -0
  63. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/queries.md +0 -0
  64. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/relationships.md +0 -0
  65. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/guide/transactions.md +0 -0
  66. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/howto/multiple-databases.md +0 -0
  67. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/howto/pagination.md +0 -0
  68. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/howto/soft-deletes.md +0 -0
  69. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/howto/testing.md +0 -0
  70. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/howto/timestamps.md +0 -0
  71. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/index.md +0 -0
  72. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/migration-sqlalchemy.md +0 -0
  73. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  74. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  75. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  76. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  77. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  78. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  79. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/README.md +0 -0
  80. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  81. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  82. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  83. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  84. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  85. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  86. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  87. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/patterns/foreign-key-index.md +0 -0
  88. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  89. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  90. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/stylesheets/extra.css +0 -0
  91. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/docs/why-ferro.md +0 -0
  92. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/justfile +0 -0
  93. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/mkdocs.yml +0 -0
  94. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/scripts/demo_queries.py +0 -0
  95. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/backend.rs +0 -0
  96. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/connection.rs +0 -0
  97. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/__init__.py +0 -0
  98. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/_annotation_utils.py +0 -0
  99. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/_core.pyi +0 -0
  100. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/_shadow_fk_types.py +0 -0
  101. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/base.py +0 -0
  102. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/composite_indexes.py +0 -0
  103. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/composite_uniques.py +0 -0
  104. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/exceptions.py +0 -0
  105. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/fields.py +0 -0
  106. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/metaclass.py +0 -0
  107. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/migrations/__init__.py +0 -0
  108. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/migrations/alembic.py +0 -0
  109. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/models.py +0 -0
  110. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/py.typed +0 -0
  111. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/query/__init__.py +0 -0
  112. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/query/builder.py +0 -0
  113. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/query/nodes.py +0 -0
  114. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/raw.py +0 -0
  115. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/relations/__init__.py +0 -0
  116. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/relations/descriptors.py +0 -0
  117. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/schema_metadata.py +0 -0
  118. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/ferro/state.py +0 -0
  119. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/schema.rs +0 -0
  120. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/src/state.rs +0 -0
  121. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/__init__.py +0 -0
  122. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/conftest.py +0 -0
  123. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/db_backends.py +0 -0
  124. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_aggregation.py +0 -0
  125. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_alembic_autogenerate.py +0 -0
  126. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_alembic_bridge.py +0 -0
  127. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_alembic_db_type.py +0 -0
  128. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_alembic_nullability.py +0 -0
  129. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_alembic_type_mapping.py +0 -0
  130. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_auto_migrate.py +0 -0
  131. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_bulk_update.py +0 -0
  132. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_composite_index.py +0 -0
  133. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_composite_unique.py +0 -0
  134. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_connection.py +0 -0
  135. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_connection_redaction.py +0 -0
  136. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_constraints.py +0 -0
  137. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_cross_emitter_parity.py +0 -0
  138. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_crud.py +0 -0
  139. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_db_backends.py +0 -0
  140. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_db_type_cross_emitter_parity.py +0 -0
  141. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_db_type_integration.py +0 -0
  142. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_db_type_typing.py +0 -0
  143. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_db_type_validation.py +0 -0
  144. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_deletion.py +0 -0
  145. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_docs_examples.py +0 -0
  146. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_documentation_features.py +0 -0
  147. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_field_wrapper.py +0 -0
  148. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_helpers.py +0 -0
  149. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_hydration.py +0 -0
  150. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_metaclass_internals.py +0 -0
  151. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_metadata.py +0 -0
  152. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_models.py +0 -0
  153. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_named_connections_integration.py +0 -0
  154. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_one_to_one.py +0 -0
  155. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_query_builder.py +0 -0
  156. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_query_typing.py +0 -0
  157. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_raw_sql.py +0 -0
  158. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_refresh.py +0 -0
  159. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_relationship_engine.py +0 -0
  160. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_schema.py +0 -0
  161. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_schema_constraints.py +0 -0
  162. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_schema_db_type_metadata.py +0 -0
  163. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_schema_enum_annotations.py +0 -0
  164. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_shadow_fk_types.py +0 -0
  165. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  166. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_static_contracts.py +0 -0
  167. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_string_search.py +0 -0
  168. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_temporal_types.py +0 -0
  169. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/tests/test_transactions.py +0 -0
  170. {ferro_orm-0.10.2 → ferro_orm-0.10.4}/uv.lock +0 -0
@@ -1,6 +1,24 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.10.4 (2026-05-24)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **query**: Cast native Postgres enum RHS in `.where()` filters
9
+ ([#64](https://github.com/syn54x/ferro-orm/pull/64),
10
+ [`7fea893`](https://github.com/syn54x/ferro-orm/commit/7fea89320fd2216a956ae32e8ae6f4829dd8fcf7))
11
+
12
+
13
+ ## v0.10.3 (2026-05-21)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **query**: Typed predicates `col == None` / `!= None` → IS NULL / IS NOT NULL
18
+ ([#62](https://github.com/syn54x/ferro-orm/pull/62),
19
+ [`fd4b53e`](https://github.com/syn54x/ferro-orm/commit/fd4b53e26e01f8cf41a9b73de7c29b6901dee786))
20
+
21
+
4
22
  ## v0.10.2 (2026-05-19)
5
23
 
6
24
  ### Bug Fixes
@@ -25,9 +25,9 @@ dependencies = [
25
25
 
26
26
  [[package]]
27
27
  name = "autocfg"
28
- version = "1.5.0"
28
+ version = "1.5.1"
29
29
  source = "registry+https://github.com/rust-lang/crates.io-index"
30
- checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
30
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
31
31
 
32
32
  [[package]]
33
33
  name = "base64"
@@ -61,9 +61,9 @@ dependencies = [
61
61
 
62
62
  [[package]]
63
63
  name = "bumpalo"
64
- version = "3.20.2"
64
+ version = "3.20.3"
65
65
  source = "registry+https://github.com/rust-lang/crates.io-index"
66
- checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
66
+ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
67
67
 
68
68
  [[package]]
69
69
  name = "byteorder"
@@ -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.4"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -711,9 +711,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
711
711
 
712
712
  [[package]]
713
713
  name = "js-sys"
714
- version = "0.3.98"
714
+ version = "0.3.99"
715
715
  source = "registry+https://github.com/rust-lang/crates.io-index"
716
- checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
716
+ checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
717
717
  dependencies = [
718
718
  "cfg-if",
719
719
  "futures-util",
@@ -1292,9 +1292,9 @@ dependencies = [
1292
1292
 
1293
1293
  [[package]]
1294
1294
  name = "serde_json"
1295
- version = "1.0.149"
1295
+ version = "1.0.150"
1296
1296
  source = "registry+https://github.com/rust-lang/crates.io-index"
1297
- checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
1297
+ checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1298
1298
  dependencies = [
1299
1299
  "itoa",
1300
1300
  "memchr",
@@ -1892,9 +1892,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
1892
1892
 
1893
1893
  [[package]]
1894
1894
  name = "wasm-bindgen"
1895
- version = "0.2.121"
1895
+ version = "0.2.122"
1896
1896
  source = "registry+https://github.com/rust-lang/crates.io-index"
1897
- checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
1897
+ checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
1898
1898
  dependencies = [
1899
1899
  "cfg-if",
1900
1900
  "once_cell",
@@ -1905,9 +1905,9 @@ dependencies = [
1905
1905
 
1906
1906
  [[package]]
1907
1907
  name = "wasm-bindgen-macro"
1908
- version = "0.2.121"
1908
+ version = "0.2.122"
1909
1909
  source = "registry+https://github.com/rust-lang/crates.io-index"
1910
- checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
1910
+ checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
1911
1911
  dependencies = [
1912
1912
  "quote",
1913
1913
  "wasm-bindgen-macro-support",
@@ -1915,9 +1915,9 @@ dependencies = [
1915
1915
 
1916
1916
  [[package]]
1917
1917
  name = "wasm-bindgen-macro-support"
1918
- version = "0.2.121"
1918
+ version = "0.2.122"
1919
1919
  source = "registry+https://github.com/rust-lang/crates.io-index"
1920
- checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
1920
+ checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
1921
1921
  dependencies = [
1922
1922
  "bumpalo",
1923
1923
  "proc-macro2",
@@ -1928,9 +1928,9 @@ dependencies = [
1928
1928
 
1929
1929
  [[package]]
1930
1930
  name = "wasm-bindgen-shared"
1931
- version = "0.2.121"
1931
+ version = "0.2.122"
1932
1932
  source = "registry+https://github.com/rust-lang/crates.io-index"
1933
- checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
1933
+ checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
1934
1934
  dependencies = [
1935
1935
  "unicode-ident",
1936
1936
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.10.2"
3
+ version = "0.10.4"
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.4
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.4"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,6 +9,7 @@ mod connection;
9
9
  mod operations;
10
10
  mod query;
11
11
  mod schema;
12
+ mod schema_bind;
12
13
  mod state;
13
14
 
14
15
  use crate::state::MODEL_REGISTRY;
@@ -560,30 +560,6 @@ async fn postgres_temporal_cast_by_column(
560
560
  Ok(out)
561
561
  }
562
562
 
563
- fn postgres_enum_type_name_for_column(
564
- col_name: &str,
565
- enum_udt: &HashMap<String, String>,
566
- col_info: Option<&serde_json::Value>,
567
- ) -> Option<String> {
568
- // db_type takes precedence over the JSON-schema enum_type_name: when the
569
- // user has asked for `text` / `varchar(N)` / etc. storage, the column is
570
- // no longer a native Postgres enum UDT and we must not CAST values to a
571
- // non-existent type. This mirrors the Alembic-side _map_to_sa_type
572
- // override. See AGENTS.md § I-1.
573
- if let Some(info) = col_info
574
- && info.get("db_type").and_then(|v| v.as_str()).is_some()
575
- {
576
- return None;
577
- }
578
-
579
- enum_udt.get(col_name).cloned().or_else(|| {
580
- col_info?
581
- .get("enum_type_name")?
582
- .as_str()
583
- .map(std::string::ToString::to_string)
584
- })
585
- }
586
-
587
563
  fn schema_property<'a>(
588
564
  schema: &'a serde_json::Value,
589
565
  col_name: &str,
@@ -630,12 +606,10 @@ fn schema_value_expr(
630
606
 
631
607
  if let serde_json::Value::String(s) = value
632
608
  && backend == SqlDialect::Postgres
633
- && let Some(tn) = postgres_enum_type_name_for_column(col_name, enum_udt, col_info)
609
+ && let Some(tn) =
610
+ crate::schema_bind::postgres_enum_type_name_for_column(col_name, enum_udt, col_info)
634
611
  {
635
- return Ok(
636
- Expr::value(sea_query::Value::String(Some(Box::new(s.clone()))))
637
- .cast_as(Alias::new(tn.as_str())),
638
- );
612
+ return Ok(crate::schema_bind::postgres_enum_string_rhs_expr(s, &tn));
639
613
  }
640
614
 
641
615
  if is_uuid_pg {
@@ -1572,7 +1546,7 @@ pub fn fetch_filtered<'py>(
1572
1546
  let name = cls.getattr("__name__")?.extract::<String>()?;
1573
1547
  let cls_py = cls.unbind();
1574
1548
 
1575
- let query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1549
+ let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1576
1550
  pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e))
1577
1551
  })?;
1578
1552
 
@@ -1582,10 +1556,10 @@ pub fn fetch_filtered<'py>(
1582
1556
  let use_identity_map = engine.is_identity_map_enabled();
1583
1557
 
1584
1558
  let table_name = name.to_lowercase();
1585
- let pg_native_enum_cols: HashSet<String> = {
1586
- let m = postgres_enum_udt_by_column(&table_name, &engine, &tx_conn, backend).await?;
1587
- m.keys().cloned().collect()
1588
- };
1559
+ let postgres_enum_udt =
1560
+ postgres_enum_udt_by_column(&table_name, &engine, &tx_conn, backend).await?;
1561
+ query_def.postgres_enum_udt = postgres_enum_udt.clone();
1562
+ let pg_native_enum_cols: HashSet<String> = postgres_enum_udt.keys().cloned().collect();
1589
1563
  // ...
1590
1564
  let (sql, bind_values, pk_col, schema_for_decode) = {
1591
1565
  let registry = MODEL_REGISTRY.read().map_err(|_| {
@@ -1762,7 +1736,7 @@ pub fn count_filtered(
1762
1736
  tx_id: Option<String>,
1763
1737
  using: Option<String>,
1764
1738
  ) -> PyResult<Bound<'_, PyAny>> {
1765
- let query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1739
+ let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1766
1740
  pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e))
1767
1741
  })?;
1768
1742
 
@@ -1770,6 +1744,8 @@ pub fn count_filtered(
1770
1744
  let (_, engine, tx_conn, backend) = active_route_for_operation(tx_id, using)?;
1771
1745
 
1772
1746
  let table_name = name.to_lowercase();
1747
+ query_def.postgres_enum_udt =
1748
+ postgres_enum_udt_by_column(&table_name, &engine, &tx_conn, backend).await?;
1773
1749
  // ... sql ...
1774
1750
  let (sql, bind_values) = {
1775
1751
  let mut select = Query::select();
@@ -1964,7 +1940,7 @@ pub fn delete_filtered(
1964
1940
  tx_id: Option<String>,
1965
1941
  using: Option<String>,
1966
1942
  ) -> PyResult<Bound<'_, PyAny>> {
1967
- let query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1943
+ let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1968
1944
  pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e))
1969
1945
  })?;
1970
1946
 
@@ -1972,6 +1948,8 @@ pub fn delete_filtered(
1972
1948
  let (_, engine, tx_conn, backend) = active_route_for_operation(tx_id, using)?;
1973
1949
 
1974
1950
  let table_name = name.to_lowercase();
1951
+ query_def.postgres_enum_udt =
1952
+ postgres_enum_udt_by_column(&table_name, &engine, &tx_conn, backend).await?;
1975
1953
  // ... sql ...
1976
1954
  let (sql, bind_values) = {
1977
1955
  let mut delete = Query::delete();
@@ -2008,7 +1986,7 @@ pub fn update_filtered(
2008
1986
  tx_id: Option<String>,
2009
1987
  using: Option<String>,
2010
1988
  ) -> PyResult<Bound<'_, PyAny>> {
2011
- let query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
1989
+ let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| {
2012
1990
  pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e))
2013
1991
  })?;
2014
1992
 
@@ -2025,6 +2003,7 @@ pub fn update_filtered(
2025
2003
 
2026
2004
  let table_name = name.to_lowercase();
2027
2005
  let enum_udt = postgres_enum_udt_by_column(&table_name, &engine, &tx_conn, backend).await?;
2006
+ query_def.postgres_enum_udt = enum_udt.clone();
2028
2007
  let uuid_columns =
2029
2008
  postgres_uuid_column_names(&table_name, &engine, &tx_conn, backend).await?;
2030
2009
  let ts_cast =