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.
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/CHANGELOG.md +9 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/Cargo.lock +3 -3
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/Cargo.toml +1 -1
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/PKG-INFO +1 -1
- ferro_orm-0.10.3/docs/solutions/issues/typed-where-null-panics-is-null.md +107 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/typed-null-binds.md +13 -2
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/pyproject.toml +1 -1
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/query.rs +139 -4
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_typed_null_binds.py +64 -36
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.gitignore +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/.python-version +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/AGENTS.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/LICENSE +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/README.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/fields.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/model.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/query.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/relationships.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/transactions.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/api/utilities.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/changelog.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/coming-soon.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/contributing.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/faq.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/backend.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/database.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/queries.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/testing.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/index.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/migration-sqlalchemy.md +0 -0
- {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
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {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
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/README.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/why-ferro.md +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/justfile +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/mkdocs.yml +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/backend.rs +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/connection.rs +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/base.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/fields.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/models.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/py.typed +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/raw.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/ferro/state.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/lib.rs +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/operations.rs +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/schema.rs +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/src/state.rs +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/__init__.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/conftest.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/db_backends.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_connection.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_constraints.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_crud.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_deletion.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_helpers.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_hydration.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_metadata.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_models.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_refresh.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_string_search.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.10.2 → ferro_orm-0.10.3}/tests/test_transactions.py +0 -0
- {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.
|
|
250
|
+
version = "1.16.0"
|
|
251
251
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
252
|
-
checksum = "
|
|
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.
|
|
297
|
+
version = "0.10.3"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"dashmap",
|
|
300
300
|
"once_cell",
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
10
|
-
what the matrix can assert. When
|
|
11
|
-
``
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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 ``
|
|
298
|
-
not fail with a Postgres OID type error.
|
|
282
|
+
"""Query filter ``col == None`` compiles to ``IS NULL`` and matches rows.
|
|
299
283
|
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
assert
|
|
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
|
|
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
|
|
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
|
{ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md
RENAMED
|
File without changes
|
|
File without changes
|
{ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md
RENAMED
|
File without changes
|
{ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md
RENAMED
|
File without changes
|
{ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md
RENAMED
|
File without changes
|
{ferro_orm-0.10.2 → ferro_orm-0.10.3}/docs/solutions/patterns/configurable-column-storage-types.md
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|