ferro-orm 0.10.1__tar.gz → 0.10.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/CHANGELOG.md +18 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/Cargo.lock +3 -3
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/Cargo.toml +1 -1
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/PKG-INFO +1 -1
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +2 -2
- ferro_orm-0.10.3/docs/solutions/issues/typed-where-null-panics-is-null.md +107 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/typed-null-binds.md +15 -4
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/pyproject.toml +1 -1
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/operations.rs +123 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/query.rs +139 -4
- ferro_orm-0.10.3/tests/test_sqlite_alembic_reconnect_hydration.py +272 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_typed_null_binds.py +64 -36
- ferro_orm-0.10.1/tests/test_sqlite_null_hydration.py +0 -71
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.gitignore +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/.python-version +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/AGENTS.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/LICENSE +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/README.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/fields.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/model.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/query.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/relationships.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/transactions.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/api/utilities.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/changelog.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/coming-soon.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/contributing.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/faq.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/backend.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/database.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/queries.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/testing.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/index.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/README.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/why-ferro.md +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/justfile +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/mkdocs.yml +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/backend.rs +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/connection.rs +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/base.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/fields.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/models.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/py.typed +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/raw.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/ferro/state.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/lib.rs +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/schema.rs +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/src/state.rs +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/__init__.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/conftest.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/db_backends.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_connection.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_constraints.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_crud.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_deletion.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_helpers.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_hydration.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_metadata.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_models.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_refresh.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_string_search.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/tests/test_transactions.py +0 -0
- {ferro_orm-0.10.1 → ferro_orm-0.10.3}/uv.lock +0 -0
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.10.3 (2026-05-21)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **query**: Typed predicates `col == None` / `!= None` → IS NULL / IS NOT NULL
|
|
9
|
+
([#62](https://github.com/syn54x/ferro-orm/pull/62),
|
|
10
|
+
[`fd4b53e`](https://github.com/syn54x/ferro-orm/commit/fd4b53e26e01f8cf41a9b73de7c29b6901dee786))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v0.10.2 (2026-05-19)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- Hydrate SQLite INTEGER-backed Decimal columns on reconnect
|
|
18
|
+
([#59](https://github.com/syn54x/ferro-orm/pull/59),
|
|
19
|
+
[`6f13906`](https://github.com/syn54x/ferro-orm/commit/6f13906850300bc9e85c1274763c49bc5b318b5d))
|
|
20
|
+
|
|
21
|
+
|
|
4
22
|
## v0.10.1 (2026-05-19)
|
|
5
23
|
|
|
6
24
|
### Bug Fixes
|
|
@@ -247,9 +247,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
|
|
247
247
|
|
|
248
248
|
[[package]]
|
|
249
249
|
name = "either"
|
|
250
|
-
version = "1.
|
|
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",
|
{ferro_orm-0.10.1 → ferro_orm-0.10.3}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md
RENAMED
|
@@ -5,7 +5,7 @@ tags: [gotcha, sqlite, hydration, bridge, rust, sqlalchemy, alembic, datetime, f
|
|
|
5
5
|
related_files:
|
|
6
6
|
- src/backend.rs
|
|
7
7
|
- src/operations.rs
|
|
8
|
-
- tests/
|
|
8
|
+
- tests/test_sqlite_alembic_reconnect_hydration.py
|
|
9
9
|
related_issues: [56]
|
|
10
10
|
related_prs: [57]
|
|
11
11
|
captured: 2026-05-18
|
|
@@ -62,7 +62,7 @@ by treating `0` as missing.
|
|
|
62
62
|
|
|
63
63
|
- Rust: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`,
|
|
64
64
|
`engine_handle_fetches_sqlite_non_null_zero_integer` in `src/backend.rs`.
|
|
65
|
-
- Python: `tests/
|
|
65
|
+
- Python: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic `create_all` + reconnect).
|
|
66
66
|
Requires `aiosqlite` and `greenlet` in `ci-test` / `dev` so CI does not skip.
|
|
67
67
|
|
|
68
68
|
## How to recognize
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Typed WHERE filters with None panic or wrong SQL (IS NULL)
|
|
3
|
+
type: issue
|
|
4
|
+
tags: [gotcha, query, filter, is-null, bridge, ffi, rust, pyo3, sea-query, serde]
|
|
5
|
+
related_files:
|
|
6
|
+
- src/query.rs
|
|
7
|
+
- src/ferro/query/nodes.py
|
|
8
|
+
- tests/test_typed_null_binds.py
|
|
9
|
+
- docs/solutions/patterns/typed-null-binds.md
|
|
10
|
+
related_issues: [41, 61]
|
|
11
|
+
related_prs: [62]
|
|
12
|
+
captured: 2026-05-20
|
|
13
|
+
last_updated: 2026-05-20
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Problem
|
|
17
|
+
|
|
18
|
+
`Model.where(lambda t: t.col == None)` or `Model.col == None` / `!= None` used to
|
|
19
|
+
panic in Rust (`node_to_condition_for_backend`) or, if it did not panic, would
|
|
20
|
+
compile to `col = NULL`, which never matches rows in SQL.
|
|
21
|
+
|
|
22
|
+
## Takeaway
|
|
23
|
+
|
|
24
|
+
Python `None` on the filter RHS becomes JSON `"value": null`, which serde
|
|
25
|
+
deserializes as `Option<serde_json::Value>::None` — not `Some(Value::Null)`.
|
|
26
|
+
For `==` / `!=`, emit `IS NULL` / `IS NOT NULL` in `node_to_condition_for_backend`;
|
|
27
|
+
never `unwrap()` `node.value` before checking for a null RHS. Bind typing for
|
|
28
|
+
other operators stays in the typed-null bind pipeline — see
|
|
29
|
+
`docs/solutions/patterns/typed-null-binds.md`.
|
|
30
|
+
|
|
31
|
+
## Explanation
|
|
32
|
+
|
|
33
|
+
**Causal chain**
|
|
34
|
+
|
|
35
|
+
1. `QueryNode.to_dict()` serializes Python `None` as JSON `null` on `value`.
|
|
36
|
+
2. Rust `QueryNode { value: Option<serde_json::Value> }` deserializes absent/null
|
|
37
|
+
as `None`, not `Some(Null)`.
|
|
38
|
+
3. Pre-fix code: `let val = node.value.as_ref().unwrap();` → panic across FFI
|
|
39
|
+
(AGENTS.md I-3).
|
|
40
|
+
4. Even without panic, `col.eq(bind_null)` yields `col = NULL`, which is always
|
|
41
|
+
unknown/false in three-valued logic — filters return no rows.
|
|
42
|
+
|
|
43
|
+
**Fix (PR #62, issue #41)**
|
|
44
|
+
|
|
45
|
+
```rust
|
|
46
|
+
let rhs_is_json_null = node.value.as_ref().map_or(true, serde_json::Value::is_null);
|
|
47
|
+
|
|
48
|
+
let expr: SimpleExpr = if rhs_is_json_null {
|
|
49
|
+
match node.operator.as_str() {
|
|
50
|
+
"==" => col.is_null(),
|
|
51
|
+
"!=" => col.is_not_null(),
|
|
52
|
+
// other ops: value_rhs_simple_expr_for_backend(..., &Value::Null, ...)
|
|
53
|
+
...
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
let val = node.value.as_ref().unwrap();
|
|
57
|
+
// existing non-null paths
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Discovery before fix (session history)**
|
|
62
|
+
|
|
63
|
+
During the April `refactor/typed-null-binds` work, the same wire shape was
|
|
64
|
+
identified and GitHub #41 was filed; `test_filter_by_none_does_not_reproduce_38`
|
|
65
|
+
stayed `xfail(strict=True)` until this fix landed on branch
|
|
66
|
+
`cursor/fix-typed-predicate-null-is-null-f3a4`.
|
|
67
|
+
|
|
68
|
+
**Tests**
|
|
69
|
+
|
|
70
|
+
| Layer | File | What it pins |
|
|
71
|
+
|-------|------|----------------|
|
|
72
|
+
| Integration | `tests/test_typed_null_binds.py` | `test_filter_by_none_does_not_reproduce_38` — FieldProxy `== None` / `!= None` |
|
|
73
|
+
| Integration | `tests/test_typed_null_binds.py` | `test_lambda_predicate_null_filter_datetime_and_json` — QueryProxy lambda on nullable datetime + JSON |
|
|
74
|
+
| Rust unit | `src/query.rs` | `json_null_deserializes_to_option_none_for_query_node_value` |
|
|
75
|
+
| Rust unit | `src/query.rs` | `where_rhs_none_emits_is_null_for_eq_sqlite` / `where_rhs_none_emits_is_not_null_for_ne_sqlite` |
|
|
76
|
+
|
|
77
|
+
## How to recognize
|
|
78
|
+
|
|
79
|
+
- Crash or opaque unwind when filtering with `== None` / `!= None` on any column
|
|
80
|
+
type (including `datetime | None` and `list | None` / JSON).
|
|
81
|
+
- Grep finds `node.value.as_ref().unwrap()` in `node_to_condition_for_backend`
|
|
82
|
+
without a prior null-RHS guard.
|
|
83
|
+
- Generated SQL contains `= null` instead of `is null` for None filters.
|
|
84
|
+
- Distinct from issue #38 (typed **bind** `null::text` on INSERT) and #56 (SQLite
|
|
85
|
+
**fetch** NULL → `int(0)`); this is the **WHERE compile** path in `src/query.rs`.
|
|
86
|
+
|
|
87
|
+
## Prevention
|
|
88
|
+
|
|
89
|
+
- Treat JSON `null` on optional Rust fields as “SQL null intent,” not as “missing
|
|
90
|
+
field” — use `map_or(true, Value::is_null)` (or equivalent) before `unwrap`.
|
|
91
|
+
- For every filter API that accepts Python `None`, add both FieldProxy and
|
|
92
|
+
QueryProxy lambda integration tests plus a Rust unit test on deserialized
|
|
93
|
+
`{"value": null}`.
|
|
94
|
+
- Keep the invariant in `typed-null-binds.md` § “IS NULL for typed `== None`”
|
|
95
|
+
when adding new schema-driven emitters.
|
|
96
|
+
|
|
97
|
+
## Related
|
|
98
|
+
|
|
99
|
+
- Pattern: `docs/solutions/patterns/typed-null-binds.md` (bind layer + IS NULL rule)
|
|
100
|
+
- Issue [#41]: https://github.com/syn54x/ferro-orm/issues/41
|
|
101
|
+
- Issue [#61]: https://github.com/syn54x/ferro-orm/issues/61 (duplicate report)
|
|
102
|
+
- PR [#62]: https://github.com/syn54x/ferro-orm/pull/62
|
|
103
|
+
- Issue [#38]: typed-null binds on Postgres (orthogonal root cause)
|
|
104
|
+
|
|
105
|
+
[#38]: https://github.com/syn54x/ferro-orm/issues/38
|
|
106
|
+
[#41]: https://github.com/syn54x/ferro-orm/issues/41
|
|
107
|
+
[#61]: https://github.com/syn54x/ferro-orm/issues/61
|
|
@@ -7,10 +7,11 @@ related_files:
|
|
|
7
7
|
- src/operations.rs
|
|
8
8
|
- src/query.rs
|
|
9
9
|
- tests/test_typed_null_binds.py
|
|
10
|
-
- tests/
|
|
11
|
-
related_issues: [38, 40, 56]
|
|
12
|
-
related_prs: []
|
|
10
|
+
- tests/test_sqlite_alembic_reconnect_hydration.py
|
|
11
|
+
related_issues: [38, 40, 41, 56]
|
|
12
|
+
related_prs: [62]
|
|
13
13
|
captured: 2026-04-29
|
|
14
|
+
last_updated: 2026-05-20
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
## Problem
|
|
@@ -72,6 +73,11 @@ digraph typed_null_flow {
|
|
|
72
73
|
| Filter null pick | `typed_null_for_column` (called by ^) | `src/query.rs` |
|
|
73
74
|
| M2M target IDs | `backend_column_value_expr` | `src/operations.rs`|
|
|
74
75
|
|
|
76
|
+
**`IS NULL` for typed `== None`:** JSON `null` on `QueryNode::value` deserializes
|
|
77
|
+
as `Option<serde_json::Value>::None` (serde), not `Some(Value::Null)`.
|
|
78
|
+
`node_to_condition_for_backend` maps `==` / `!=` with a null RHS to
|
|
79
|
+
`IS NULL` / `IS NOT NULL` — never `= NULL`, which is never true in SQL.
|
|
80
|
+
|
|
75
81
|
These functions inspect column metadata (JSON type, format, `uuid_columns`
|
|
76
82
|
introspection, `ts_cast` metadata) and emit one of the typed SeaQuery `None`
|
|
77
83
|
variants:
|
|
@@ -112,7 +118,7 @@ typed `try_get`. Non-null integer `0` is unchanged; only SQL `NULL` is affected.
|
|
|
112
118
|
| Fetch (read) | `materialize_engine_row` | `is_null()` before `decode_non_null_*`|
|
|
113
119
|
|
|
114
120
|
Rust regression: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`.
|
|
115
|
-
Integration: `tests/
|
|
121
|
+
Integration: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic schema + reconnect).
|
|
116
122
|
|
|
117
123
|
## Raw-SQL boundary (explicit exception)
|
|
118
124
|
|
|
@@ -193,7 +199,12 @@ Ferro itself.
|
|
|
193
199
|
NULL → `int(0)` on SQLite (issue [#56]).
|
|
194
200
|
- Issue [#38]: the original bug report.
|
|
195
201
|
- Issue [#40]: temporal typed binds (deferred follow-up).
|
|
202
|
+
- Issue [#41]: filter `== None` panic / `IS NULL` compile path — debugging story in
|
|
203
|
+
`docs/solutions/issues/typed-where-null-panics-is-null.md`.
|
|
204
|
+
- PR [#62]: `fix(query): typed predicates col == None → IS NULL / IS NOT NULL`.
|
|
196
205
|
|
|
197
206
|
[#38]: https://github.com/syn54x/ferro-orm/issues/38
|
|
198
207
|
[#40]: https://github.com/syn54x/ferro-orm/issues/40
|
|
208
|
+
[#41]: https://github.com/syn54x/ferro-orm/issues/41
|
|
199
209
|
[#56]: https://github.com/syn54x/ferro-orm/issues/56
|
|
210
|
+
[#62]: https://github.com/syn54x/ferro-orm/pull/62
|
|
@@ -193,6 +193,7 @@ fn engine_value_to_rust_value(
|
|
|
193
193
|
|
|
194
194
|
if is_decimal {
|
|
195
195
|
return match value {
|
|
196
|
+
EngineValue::I64(v) => RustValue::Decimal(v.to_string()),
|
|
196
197
|
EngineValue::F64(v) => RustValue::Decimal(v.to_string()),
|
|
197
198
|
EngineValue::String(v) => RustValue::Decimal(v),
|
|
198
199
|
_ => RustValue::None,
|
|
@@ -3077,3 +3078,125 @@ mod raw_sql_tests {
|
|
|
3077
3078
|
});
|
|
3078
3079
|
}
|
|
3079
3080
|
}
|
|
3081
|
+
|
|
3082
|
+
#[cfg(test)]
|
|
3083
|
+
mod engine_value_to_rust_value_tests {
|
|
3084
|
+
use super::engine_value_to_rust_value;
|
|
3085
|
+
use crate::backend::EngineValue;
|
|
3086
|
+
use crate::state::RustValue;
|
|
3087
|
+
|
|
3088
|
+
fn decimal_schema() -> serde_json::Value {
|
|
3089
|
+
serde_json::json!({
|
|
3090
|
+
"properties": {
|
|
3091
|
+
"hours": {
|
|
3092
|
+
"format": "decimal",
|
|
3093
|
+
"anyOf": [
|
|
3094
|
+
{"type": "number"},
|
|
3095
|
+
{"type": "string", "pattern": "^-?\\d+(\\.\\d+)?$"}
|
|
3096
|
+
]
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
})
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
#[test]
|
|
3103
|
+
fn decimal_column_maps_sqlite_integer_affinity_to_decimal() {
|
|
3104
|
+
let schema = decimal_schema();
|
|
3105
|
+
let out = engine_value_to_rust_value(EngineValue::I64(3), &schema, "hours");
|
|
3106
|
+
assert!(matches!(out, RustValue::Decimal(ref s) if s == "3"));
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
#[test]
|
|
3110
|
+
fn decimal_column_maps_real_and_text() {
|
|
3111
|
+
let schema = decimal_schema();
|
|
3112
|
+
let from_real =
|
|
3113
|
+
engine_value_to_rust_value(EngineValue::F64(1.5), &schema, "hours");
|
|
3114
|
+
assert!(matches!(from_real, RustValue::Decimal(ref s) if s == "1.5"));
|
|
3115
|
+
let from_text =
|
|
3116
|
+
engine_value_to_rust_value(EngineValue::String("2.25".into()), &schema, "hours");
|
|
3117
|
+
assert!(matches!(from_text, RustValue::Decimal(ref s) if s == "2.25"));
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
fn datetime_schema() -> serde_json::Value {
|
|
3121
|
+
serde_json::json!({
|
|
3122
|
+
"properties": {
|
|
3123
|
+
"happened_at": {"type": "string", "format": "date-time"}
|
|
3124
|
+
}
|
|
3125
|
+
})
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
fn binary_schema() -> serde_json::Value {
|
|
3129
|
+
serde_json::json!({
|
|
3130
|
+
"properties": {
|
|
3131
|
+
"data": {"type": "string", "format": "binary"}
|
|
3132
|
+
}
|
|
3133
|
+
})
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
fn bool_schema() -> serde_json::Value {
|
|
3137
|
+
serde_json::json!({
|
|
3138
|
+
"properties": {
|
|
3139
|
+
"is_active": {"type": "boolean"}
|
|
3140
|
+
}
|
|
3141
|
+
})
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
fn json_schema() -> serde_json::Value {
|
|
3145
|
+
serde_json::json!({
|
|
3146
|
+
"properties": {
|
|
3147
|
+
"payload": {"type": "object"}
|
|
3148
|
+
}
|
|
3149
|
+
})
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
#[test]
|
|
3153
|
+
fn datetime_column_only_accepts_string_engine_values() {
|
|
3154
|
+
let schema = datetime_schema();
|
|
3155
|
+
let ok = engine_value_to_rust_value(
|
|
3156
|
+
EngineValue::String("2026-04-24T18:30:00+00:00".into()),
|
|
3157
|
+
&schema,
|
|
3158
|
+
"happened_at",
|
|
3159
|
+
);
|
|
3160
|
+
assert!(matches!(ok, RustValue::DateTime(_)));
|
|
3161
|
+
let from_int = engine_value_to_rust_value(EngineValue::I64(1713984600), &schema, "happened_at");
|
|
3162
|
+
assert!(matches!(from_int, RustValue::BigInt(1713984600)));
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
#[test]
|
|
3166
|
+
fn binary_column_maps_bytes_and_text() {
|
|
3167
|
+
let schema = binary_schema();
|
|
3168
|
+
let from_bytes =
|
|
3169
|
+
engine_value_to_rust_value(EngineValue::Bytes(vec![1, 2, 3]), &schema, "data");
|
|
3170
|
+
assert!(matches!(from_bytes, RustValue::Blob(v) if v == vec![1, 2, 3]));
|
|
3171
|
+
let from_text =
|
|
3172
|
+
engine_value_to_rust_value(EngineValue::String("abc".into()), &schema, "data");
|
|
3173
|
+
assert!(matches!(from_text, RustValue::Blob(v) if v == b"abc".to_vec()));
|
|
3174
|
+
let from_int = engine_value_to_rust_value(EngineValue::I64(1), &schema, "data");
|
|
3175
|
+
assert!(matches!(from_int, RustValue::None));
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
#[test]
|
|
3179
|
+
fn bool_column_maps_integer_and_bool() {
|
|
3180
|
+
let schema = bool_schema();
|
|
3181
|
+
assert!(matches!(
|
|
3182
|
+
engine_value_to_rust_value(EngineValue::I64(1), &schema, "is_active"),
|
|
3183
|
+
RustValue::Bool(true)
|
|
3184
|
+
));
|
|
3185
|
+
assert!(matches!(
|
|
3186
|
+
engine_value_to_rust_value(EngineValue::Bool(false), &schema, "is_active"),
|
|
3187
|
+
RustValue::Bool(false)
|
|
3188
|
+
));
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
#[test]
|
|
3192
|
+
fn json_column_parses_string_payload() {
|
|
3193
|
+
let schema = json_schema();
|
|
3194
|
+
let out = engine_value_to_rust_value(
|
|
3195
|
+
EngineValue::String(r#"{"k":"v"}"#.into()),
|
|
3196
|
+
&schema,
|
|
3197
|
+
"payload",
|
|
3198
|
+
);
|
|
3199
|
+
assert!(matches!(out, RustValue::Json(v) if v.get("k").and_then(|x| x.as_str()) == Some("v")));
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
}
|
|
@@ -63,10 +63,75 @@ impl QueryDef {
|
|
|
63
63
|
}
|
|
64
64
|
} else {
|
|
65
65
|
let col_name = node.column.as_ref().unwrap();
|
|
66
|
-
let val = node.value.as_ref().unwrap();
|
|
67
66
|
let col = Expr::col(Alias::new(col_name));
|
|
68
67
|
|
|
69
|
-
|
|
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");
|