ferro-orm 0.10.0__tar.gz → 0.10.2__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.0 → ferro_orm-0.10.2}/CHANGELOG.md +24 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/Cargo.lock +1 -1
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/Cargo.toml +1 -1
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/PKG-INFO +1 -1
- ferro_orm-0.10.2/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +92 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/shadow-fk-columns.md +3 -1
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/typed-null-binds.md +30 -2
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/pyproject.toml +5 -1
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/backend.rs +94 -15
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/operations.rs +123 -0
- ferro_orm-0.10.2/tests/test_db_type_integration.py +383 -0
- ferro_orm-0.10.2/tests/test_sqlite_alembic_reconnect_hydration.py +272 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/uv.lock +21 -1
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.gitignore +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/.python-version +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/AGENTS.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/LICENSE +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/README.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/exceptions.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/fields.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/model.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/query.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/raw-sql.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/relationships.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/transactions.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/api/utilities.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/changelog.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/coming-soon.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/query-typing.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/contributing.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/faq.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/getting-started/tutorial.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/backend.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/database.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/migrations.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/models-and-fields.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/queries.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/relationships.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/testing.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/index.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/migration-sqlalchemy.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/README.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/docs/why-ferro.md +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/justfile +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/mkdocs.yml +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/connection.rs +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/base.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/fields.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/models.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/py.typed +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/raw.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/ferro/state.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/lib.rs +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/query.rs +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/schema.rs +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/src/state.rs +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/__init__.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/conftest.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/db_backends.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_connection.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_constraints.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_crud.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_deletion.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_helpers.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_hydration.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_metadata.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_models.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_refresh.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_string_search.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_transactions.py +0 -0
- {ferro_orm-0.10.0 → ferro_orm-0.10.2}/tests/test_typed_null_binds.py +0 -0
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.10.2 (2026-05-19)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Hydrate SQLite INTEGER-backed Decimal columns on reconnect
|
|
9
|
+
([#59](https://github.com/syn54x/ferro-orm/pull/59),
|
|
10
|
+
[`6f13906`](https://github.com/syn54x/ferro-orm/commit/6f13906850300bc9e85c1274763c49bc5b318b5d))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v0.10.1 (2026-05-19)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- **sqlite**: Hydrate SQL NULL as None instead of int 0
|
|
18
|
+
([#57](https://github.com/syn54x/ferro-orm/pull/57),
|
|
19
|
+
[`249c81f`](https://github.com/syn54x/ferro-orm/commit/249c81f4b37f117c3ca80f44a9682511154ab9ec))
|
|
20
|
+
|
|
21
|
+
### Testing
|
|
22
|
+
|
|
23
|
+
- **schema**: Integration coverage for db_type / db_check
|
|
24
|
+
([#55](https://github.com/syn54x/ferro-orm/pull/55),
|
|
25
|
+
[`b97d596`](https://github.com/syn54x/ferro-orm/commit/b97d59667d520c026d8a6fbb73ad1de7f71593b4))
|
|
26
|
+
|
|
27
|
+
|
|
4
28
|
## v0.10.0 (2026-05-18)
|
|
5
29
|
|
|
6
30
|
### Features
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SQLite NULL columns hydrate as int 0 after reconnect (Alembic DATETIME)
|
|
3
|
+
type: issue
|
|
4
|
+
tags: [gotcha, sqlite, hydration, bridge, rust, sqlalchemy, alembic, datetime, ffi]
|
|
5
|
+
related_files:
|
|
6
|
+
- src/backend.rs
|
|
7
|
+
- src/operations.rs
|
|
8
|
+
- tests/test_sqlite_alembic_reconnect_hydration.py
|
|
9
|
+
related_issues: [56]
|
|
10
|
+
related_prs: [57]
|
|
11
|
+
captured: 2026-05-18
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Problem
|
|
15
|
+
|
|
16
|
+
Optional fields that are `NULL` in a SQLite database (for example
|
|
17
|
+
`archived_at: datetime | None`) sometimes appear as Python `int(0)` after a
|
|
18
|
+
**new connection** — `ferro.reset_engine()`, a new CLI process, or any fetch
|
|
19
|
+
that does not hit the identity map. The same row shows `None` on the connection
|
|
20
|
+
that just inserted it, and `sqlite3` confirms the column is `NULL`.
|
|
21
|
+
|
|
22
|
+
## Takeaway
|
|
23
|
+
|
|
24
|
+
`materialize_engine_row` in `src/backend.rs` must call `ValueRef::is_null()` on
|
|
25
|
+
the raw column **before** typed `try_get` decoding. On SQLite, `try_get::<i64>`
|
|
26
|
+
on SQL `NULL` often succeeds with `0` for INTEGER/NUMERIC-affinity columns
|
|
27
|
+
(including Alembic `DateTime()` → `DATETIME`). That is not limited to datetimes:
|
|
28
|
+
`INTEGER`, `REAL`, `TEXT`, and `NUMERIC` NULLs were all misread as `0` before
|
|
29
|
+
the fix.
|
|
30
|
+
|
|
31
|
+
## Explanation
|
|
32
|
+
|
|
33
|
+
**Causal chain**
|
|
34
|
+
|
|
35
|
+
1. Alembic/SQLAlchemy creates nullable `archived_at` as `DATETIME` (numeric
|
|
36
|
+
affinity on SQLite).
|
|
37
|
+
2. Insert leaves the column SQL `NULL`.
|
|
38
|
+
3. Fetch uses `materialize_engine_row`, which tried `try_get::<i64>` first.
|
|
39
|
+
4. sqlx returns `Ok(0)` for NULL on those affinities instead of an error.
|
|
40
|
+
5. `engine_value_to_rust_value` maps `EngineValue::I64` to `RustValue::BigInt`
|
|
41
|
+
(date-time `format` only applies to `EngineValue::String`).
|
|
42
|
+
6. Python sees `0`, so `archived_at is None` filters fail.
|
|
43
|
+
|
|
44
|
+
**Why same-session `create()` looked fine**
|
|
45
|
+
|
|
46
|
+
The identity map returns the in-memory instance from insert without re-reading
|
|
47
|
+
the row from SQLite.
|
|
48
|
+
|
|
49
|
+
**Fix (PR #57)**
|
|
50
|
+
|
|
51
|
+
```rust
|
|
52
|
+
let value = match row.try_get_raw(ordinal) {
|
|
53
|
+
Ok(raw) if raw.is_null() => EngineValue::Null,
|
|
54
|
+
Ok(_) | Err(_) => decode_non_null_engine_value(row, ordinal),
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Legitimate integer `0` is unchanged: SQL `NULL` is detected via `is_null()`, not
|
|
59
|
+
by treating `0` as missing.
|
|
60
|
+
|
|
61
|
+
**Tests**
|
|
62
|
+
|
|
63
|
+
- Rust: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`,
|
|
64
|
+
`engine_handle_fetches_sqlite_non_null_zero_integer` in `src/backend.rs`.
|
|
65
|
+
- Python: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic `create_all` + reconnect).
|
|
66
|
+
Requires `aiosqlite` and `greenlet` in `ci-test` / `dev` so CI does not skip.
|
|
67
|
+
|
|
68
|
+
## How to recognize
|
|
69
|
+
|
|
70
|
+
- Bug only on **SQLite**, often with **Alembic-created** schema (`auto_migrate=False`).
|
|
71
|
+
- `field is None` checks fail while raw SQL shows `NULL`.
|
|
72
|
+
- Same process right after `create()` is correct; new connection or `reset_engine()` is wrong.
|
|
73
|
+
- Affected value is `0` (int), not a datetime string or `AttributeError`.
|
|
74
|
+
- Raw `ferro.raw.fetch_all` shows the same `0` — failure is in row materialization, not Pydantic coercion alone.
|
|
75
|
+
|
|
76
|
+
## Prevention
|
|
77
|
+
|
|
78
|
+
- Any change to `materialize_engine_row` or `EngineValue` decoding: add a Rust
|
|
79
|
+
test that inserts `DEFAULT VALUES` into a nullable column and asserts
|
|
80
|
+
`EngineValue::Null`.
|
|
81
|
+
- Do not “fix” optional datetimes in Python by treating `0` as unset — fix NULL
|
|
82
|
+
detection at the bridge.
|
|
83
|
+
- Related but distinct: typed **bind** NULLs for Postgres are documented in
|
|
84
|
+
`docs/solutions/patterns/typed-null-binds.md` (`NullKind`, insert/update paths).
|
|
85
|
+
Fetch-time NULL handling is separate; both layers need correct typing.
|
|
86
|
+
|
|
87
|
+
## Related
|
|
88
|
+
|
|
89
|
+
- GitHub issue: https://github.com/syn54x/ferro-orm/issues/56
|
|
90
|
+
- PR: https://github.com/syn54x/ferro-orm/pull/57
|
|
91
|
+
- `docs/solutions/patterns/typed-null-binds.md` — NULL on the **write** path
|
|
92
|
+
- `docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md` — other hydration footguns
|
|
@@ -5,7 +5,9 @@ tags: [convention, schema, relationships, pydantic]
|
|
|
5
5
|
related_files:
|
|
6
6
|
- src/ferro/base.py
|
|
7
7
|
- src/ferro/schema_metadata.py
|
|
8
|
-
- src/ferro/
|
|
8
|
+
- src/ferro/metaclass.py
|
|
9
|
+
- src/ferro/_shadow_fk_types.py
|
|
10
|
+
- src/ferro/relations/__init__.py
|
|
9
11
|
related_issues: [32]
|
|
10
12
|
related_prs: [36]
|
|
11
13
|
captured: 2026-04-28
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Typed-null binds at the SQLx boundary
|
|
3
3
|
type: pattern
|
|
4
|
-
tags: [convention, invariant, bridge, ffi, rust, sea-query, sqlx, postgres]
|
|
4
|
+
tags: [convention, invariant, bridge, ffi, rust, sea-query, sqlx, postgres, sqlite]
|
|
5
5
|
related_files:
|
|
6
6
|
- src/backend.rs
|
|
7
7
|
- src/operations.rs
|
|
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]
|
|
11
12
|
related_prs: []
|
|
12
13
|
captured: 2026-04-29
|
|
13
14
|
---
|
|
@@ -92,6 +93,27 @@ are deferred (see plan §3 Scope Boundaries).
|
|
|
92
93
|
\*\* Temporal types continue to use `cast_as` until issue [#40] picks a
|
|
93
94
|
datetime crate (`chrono` vs `time`).
|
|
94
95
|
|
|
96
|
+
## Fetch-time row materialization (read path)
|
|
97
|
+
|
|
98
|
+
Typed binds fix **outbound** NULL parameters. A separate failure mode is
|
|
99
|
+
**inbound** row decoding in `materialize_engine_row` (`src/backend.rs`).
|
|
100
|
+
|
|
101
|
+
On SQLite, `row.try_get::<i64>` on SQL `NULL` often returns `Ok(0)` for
|
|
102
|
+
INTEGER/NUMERIC-affinity columns (including Alembic `DateTime()` → `DATETIME`)
|
|
103
|
+
if you decode before checking nullness. Ferro then hydrates Python `int(0)`
|
|
104
|
+
instead of `None` — see issue [#56].
|
|
105
|
+
|
|
106
|
+
**Rule:** call `row.try_get_raw(ordinal)` and test `raw.is_null()` before any
|
|
107
|
+
typed `try_get`. Non-null integer `0` is unchanged; only SQL `NULL` is affected.
|
|
108
|
+
|
|
109
|
+
| Layer | Function / path | NULL concern |
|
|
110
|
+
|------------------|------------------------------|---------------------------------------|
|
|
111
|
+
| Bind (write) | `bind_engine_value` | `NullKind` → `Option::<T>::None` |
|
|
112
|
+
| Fetch (read) | `materialize_engine_row` | `is_null()` before `decode_non_null_*`|
|
|
113
|
+
|
|
114
|
+
Rust regression: `engine_handle_fetches_sqlite_null_columns_as_null_not_zero`.
|
|
115
|
+
Integration: `tests/test_sqlite_alembic_reconnect_hydration.py` (Alembic schema + reconnect).
|
|
116
|
+
|
|
95
117
|
## Raw-SQL boundary (explicit exception)
|
|
96
118
|
|
|
97
119
|
The raw-SQL bind path (`src/operations.rs::python_to_engine_bind_value`,
|
|
@@ -120,6 +142,9 @@ Ferro itself.
|
|
|
120
142
|
to `Null(Untyped)` -- non-null data goes out as a text-typed null, and
|
|
121
143
|
Postgres reports `expression is of type text` rather than the actual
|
|
122
144
|
type mismatch. Always pair `Some` and `None` arms when adding a type.
|
|
145
|
+
- On SQLite, optional fields are `NULL` in the DB but Python sees `int(0)`
|
|
146
|
+
after reconnect — check `materialize_engine_row`, not bind typing alone
|
|
147
|
+
([#56]).
|
|
123
148
|
|
|
124
149
|
## Recipe: adding a new schema-driven emitter
|
|
125
150
|
|
|
@@ -164,8 +189,11 @@ Ferro itself.
|
|
|
164
189
|
- AGENTS.md I-3: no `unwrap()` across the FFI boundary.
|
|
165
190
|
- `docs/plans/2026-04-29-001-typed-null-binds-plan.md`: the implementation
|
|
166
191
|
plan with the unit-by-unit breakdown.
|
|
192
|
+
- `docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md`: fetch-time
|
|
193
|
+
NULL → `int(0)` on SQLite (issue [#56]).
|
|
167
194
|
- Issue [#38]: the original bug report.
|
|
168
195
|
- Issue [#40]: temporal typed binds (deferred follow-up).
|
|
169
196
|
|
|
170
197
|
[#38]: https://github.com/syn54x/ferro-orm/issues/38
|
|
171
198
|
[#40]: https://github.com/syn54x/ferro-orm/issues/40
|
|
199
|
+
[#56]: https://github.com/syn54x/ferro-orm/issues/56
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ferro-orm"
|
|
3
|
-
version = "0.10.
|
|
3
|
+
version = "0.10.2"
|
|
4
4
|
description = "A high-performance, Rust-backed ORM for Python."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -43,6 +43,8 @@ ci-test = [
|
|
|
43
43
|
"pytest-cov>=7.0.0",
|
|
44
44
|
"pytest-examples>=0.0.18",
|
|
45
45
|
"pytest-postgresql>=8.0.0",
|
|
46
|
+
"aiosqlite>=0.22.1",
|
|
47
|
+
"greenlet>=3.3.1",
|
|
46
48
|
]
|
|
47
49
|
docs = [
|
|
48
50
|
"mkdocs-material>=9.5.0",
|
|
@@ -74,6 +76,8 @@ dev = [
|
|
|
74
76
|
"psycopg[binary]>=3.3.3",
|
|
75
77
|
"pytest-postgresql>=8.0.0",
|
|
76
78
|
"pytest-xdist>=3.8.0",
|
|
79
|
+
"aiosqlite>=0.22.1",
|
|
80
|
+
"greenlet>=3.3.1",
|
|
77
81
|
]
|
|
78
82
|
|
|
79
83
|
[tool.pytest.ini_options]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
use sqlx::ColumnIndex;
|
|
2
2
|
use sqlx::pool::PoolConnection;
|
|
3
|
-
use sqlx::{Column, PgPool, Postgres, Row, Sqlite, SqlitePool};
|
|
3
|
+
use sqlx::{Column, PgPool, Postgres, Row, Sqlite, SqlitePool, ValueRef};
|
|
4
4
|
use std::fmt;
|
|
5
5
|
use std::sync::Arc;
|
|
6
6
|
|
|
@@ -360,20 +360,13 @@ where
|
|
|
360
360
|
.iter()
|
|
361
361
|
.map(|column| {
|
|
362
362
|
let name = column.name().to_string();
|
|
363
|
-
let
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
EngineValue::
|
|
369
|
-
|
|
370
|
-
EngineValue::String(value)
|
|
371
|
-
} else if let Ok(value) = row.try_get::<Vec<u8>, _>(column.ordinal()) {
|
|
372
|
-
EngineValue::Bytes(value)
|
|
373
|
-
} else if let Ok(value) = row.try_get::<bool, _>(column.ordinal()) {
|
|
374
|
-
EngineValue::Bool(value)
|
|
375
|
-
} else {
|
|
376
|
-
EngineValue::Null
|
|
363
|
+
let ordinal = column.ordinal();
|
|
364
|
+
// SQLite (and some drivers) let `try_get::<i64>` succeed with 0 on SQL
|
|
365
|
+
// NULL when the column has INTEGER/NUMERIC affinity (including Alembic
|
|
366
|
+
// `DATETIME`). Always consult the raw value before typed decode.
|
|
367
|
+
let value = match row.try_get_raw(ordinal) {
|
|
368
|
+
Ok(raw) if raw.is_null() => EngineValue::Null,
|
|
369
|
+
Ok(_) | Err(_) => decode_non_null_engine_value(row, ordinal),
|
|
377
370
|
};
|
|
378
371
|
(name, value)
|
|
379
372
|
})
|
|
@@ -382,6 +375,34 @@ where
|
|
|
382
375
|
EngineRow { values }
|
|
383
376
|
}
|
|
384
377
|
|
|
378
|
+
fn decode_non_null_engine_value<R>(row: &R, ordinal: usize) -> EngineValue
|
|
379
|
+
where
|
|
380
|
+
R: Row,
|
|
381
|
+
for<'r> i32: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
|
|
382
|
+
for<'r> i64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
|
|
383
|
+
for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
|
|
384
|
+
for<'r> Vec<u8>: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
|
|
385
|
+
for<'r> String: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
|
|
386
|
+
for<'r> bool: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
|
|
387
|
+
usize: ColumnIndex<R>,
|
|
388
|
+
{
|
|
389
|
+
if let Ok(value) = row.try_get::<i64, _>(ordinal) {
|
|
390
|
+
EngineValue::I64(value)
|
|
391
|
+
} else if let Ok(value) = row.try_get::<i32, _>(ordinal) {
|
|
392
|
+
EngineValue::I64(i64::from(value))
|
|
393
|
+
} else if let Ok(value) = row.try_get::<f64, _>(ordinal) {
|
|
394
|
+
EngineValue::F64(value)
|
|
395
|
+
} else if let Ok(value) = row.try_get::<String, _>(ordinal) {
|
|
396
|
+
EngineValue::String(value)
|
|
397
|
+
} else if let Ok(value) = row.try_get::<Vec<u8>, _>(ordinal) {
|
|
398
|
+
EngineValue::Bytes(value)
|
|
399
|
+
} else if let Ok(value) = row.try_get::<bool, _>(ordinal) {
|
|
400
|
+
EngineValue::Bool(value)
|
|
401
|
+
} else {
|
|
402
|
+
EngineValue::Null
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
385
406
|
fn bind_engine_value<'q, DB>(
|
|
386
407
|
query: sqlx::query::Query<'q, DB, <DB as sqlx::Database>::Arguments<'q>>,
|
|
387
408
|
value: &'q EngineBindValue,
|
|
@@ -568,6 +589,64 @@ mod tests {
|
|
|
568
589
|
assert_eq!(inserted, 1);
|
|
569
590
|
}
|
|
570
591
|
|
|
592
|
+
#[tokio::test]
|
|
593
|
+
async fn engine_handle_fetches_sqlite_null_columns_as_null_not_zero() {
|
|
594
|
+
let pool = SqlitePoolOptions::new()
|
|
595
|
+
.max_connections(1)
|
|
596
|
+
.connect("sqlite::memory:")
|
|
597
|
+
.await
|
|
598
|
+
.unwrap();
|
|
599
|
+
let engine = EngineHandle::new_sqlite(pool);
|
|
600
|
+
|
|
601
|
+
for (table, ddl) in [
|
|
602
|
+
("null_int", "CREATE TABLE null_int (v INTEGER)"),
|
|
603
|
+
("null_real", "CREATE TABLE null_real (v REAL)"),
|
|
604
|
+
("null_text", "CREATE TABLE null_text (v TEXT)"),
|
|
605
|
+
("null_datetime", "CREATE TABLE null_datetime (v DATETIME)"),
|
|
606
|
+
] {
|
|
607
|
+
engine.execute_sql(ddl).await.unwrap();
|
|
608
|
+
engine
|
|
609
|
+
.execute_sql(&format!("INSERT INTO {table} DEFAULT VALUES"))
|
|
610
|
+
.await
|
|
611
|
+
.unwrap();
|
|
612
|
+
let rows = engine
|
|
613
|
+
.fetch_all_sql_with_binds(&format!("SELECT v FROM {table}"), &[])
|
|
614
|
+
.await
|
|
615
|
+
.unwrap();
|
|
616
|
+
assert_eq!(
|
|
617
|
+
rows[0].values[0].1,
|
|
618
|
+
EngineValue::Null,
|
|
619
|
+
"SQL NULL in {table} must not decode as integer zero"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#[tokio::test]
|
|
625
|
+
async fn engine_handle_fetches_sqlite_non_null_zero_integer() {
|
|
626
|
+
let pool = SqlitePoolOptions::new()
|
|
627
|
+
.max_connections(1)
|
|
628
|
+
.connect("sqlite::memory:")
|
|
629
|
+
.await
|
|
630
|
+
.unwrap();
|
|
631
|
+
let engine = EngineHandle::new_sqlite(pool);
|
|
632
|
+
|
|
633
|
+
engine
|
|
634
|
+
.execute_sql("CREATE TABLE zero_int (v INTEGER NOT NULL)")
|
|
635
|
+
.await
|
|
636
|
+
.unwrap();
|
|
637
|
+
engine
|
|
638
|
+
.execute_sql("INSERT INTO zero_int (v) VALUES (0)")
|
|
639
|
+
.await
|
|
640
|
+
.unwrap();
|
|
641
|
+
|
|
642
|
+
let rows = engine
|
|
643
|
+
.fetch_all_sql_with_binds("SELECT v FROM zero_int", &[])
|
|
644
|
+
.await
|
|
645
|
+
.unwrap();
|
|
646
|
+
|
|
647
|
+
assert_eq!(rows[0].values[0].1, EngineValue::I64(0));
|
|
648
|
+
}
|
|
649
|
+
|
|
571
650
|
#[tokio::test]
|
|
572
651
|
async fn engine_handle_fetches_sqlite_rows_with_bound_values() {
|
|
573
652
|
let pool = SqlitePoolOptions::new()
|
|
@@ -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
|
+
}
|