Skip to content

fix: JSON-family fields hydrate as their declared type (#737) - #832

Merged
0x054 merged 4 commits into
mainfrom
sdd/737-json-family-fields-hydrate-as-declared-type
Oct 30, 2026
Merged

0x054 merged 4 commits into
mainfrom
sdd/737-json-family-fields-hydrate-as-declared-type

Conversation

@0x054

@0x054 0x054 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #737

What

A JSON-family field whose annotation the JSON decoder cannot produce (nested model, set, tuple, datetime, UUID, Decimal, enum, at any nesting; dict with non-string keys) now hydrates as its declared type. ColumnSpec.json_adapter holds the field's TypeAdapter, decided once from the annotation, and the metaclass registers ("json", adapter.validate_python) in _hydration_converters. Fetch, projections, same-session refresh and RETURNING all read that registry, so none needed a change; the Rust hydrator is untouched. Native annotations (dict[str, int], list[str], dict, list) register nothing and stay zero-copy. The models-and-fields guide's running example gains a nested Dimensions field in both declaration styles.

Verify

Ran the ticket's Verify block on 114b7826: passed (SQLite and Postgres 18; docs example, docs lint).
TDD: RED pytest tests/test_json_field_hydration.py --db-backends=sqlite,postgres failed before the change (12 failed, 5 passed: dict/list/str values instead of declared types, no json entries in the registry); GREEN the same command passed after it (17/17).
Verify block corrected: no

Tests by scenario: nested model, rich types (set, tuple, list[datetime], dict[str, UUID], list[Decimal], dict[str, StrEnum]) with model_dump_json() under warnings=error, NULL optional model, native fields (no registry entry, values unchanged), Row projection, same-session refresh, RETURNING (_hydrated_returning directly: no JSON column can be generated, so no write returns one today), and a stored document missing a key raising Failed to hydrate json field JShade.dimensions. Any is not a column type in Ferro (unknown logical_type), so the ticket's Any case is covered by _json_needs_validation treating it as native, not by a model.

Interfaces

Interfaces changed: none (ColumnSpec.json_adapter: TypeAdapter[Any] | None, excluded from ColumnSpec equality and repr; ("json", adapter.validate_python) in the registry).

Laws

  • AGENTS.md#I-2: a hydrated JSON field now equals a constructed one (same validation as __init__); native fields keep the zero-copy path
  • AGENTS.md#I-6: a rejected document raises Failed to hydrate json field ...; no pass-through
  • AGENTS.md#I-7: the guide's new field is shown in both tabs, backed by models_and_fields.py and its _annotated twin
  • AGENTS.md#I-8, I-14: docs lint run over the page and both examples, clean
  • AGENTS.md#I-10: no CHANGELOG edit
  • AGENTS.md#I-1, I-3, I-13, I-15, I-16: n/a: no DDL, FFI, wire-scalar, statement-presentation or write-order change (the column's storage type is unchanged)
  • GLOSSARY.md: n/a: no term added or renamed

Notes for the reviewer

Outside Files owned:

  • tests/test_structural_types.py: two assertions pinned the bug ("reload yields dicts" for list[BaseModel], entries == [e.model_dump() ...]); they now assert the models.

Merged origin/main (cf1ee43), which carries the test_postponed_annotations.py fix (#831); Verify re-run on the merged tree.

The docs example's DDL string gained "dimensions" JSON,; main() asserts it against the statements create_tables() ran, and test_docs_examples.py is green.

Full local suite (xdist, SQLite + Postgres 18): before the merge, only the two items above failed.

🤖 Generated with Claude Code

A JSON field whose annotation the decoder cannot produce (model, set, tuple,
datetime, UUID, ...) registers a json converter: its type adapter validates
the decoded value. Native JSON annotations keep the zero-copy path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec: PASS

  • Minor · [ticket#Acceptance criteria] · tests/test_json_field_hydration.py:39 at c4b7040 · Missing · the Postgres runs exercise only the derived jsonb column; no model declares db_type="json", so the "Postgres (JSON and jsonb)" half of the first criterion has no test. A probe I ran (nested model under db_type="json", Postgres 18) hydrates correctly, so the behaviour is there; only the pin is missing · the ticket asks for both on Postgres
  • Minor · [ticket#Test scenarios] · tests/test_json_field_hydration.py:55 at c4b7040 · Missing · the "Any → no registry entry" edge case has no test. The PR body's reason holds (I checked: x: Any is refused at connect with unknown logical_type 'unknown'), but dict[str, Any] and list[Any] are valid JSON columns that reach the Any branch of _json_needs_validation, and JNative declares neither · the ticket lists Any among the native annotations
    Cannot verify: none

Checked and accepted:

  • tests/test_structural_types.py is outside Files owned. The PR body declares it, and the two assertions it replaces pinned the bug (isinstance(it, dict), entries == [e.model_dump() ...]).
  • RETURNING scenario: build_insert (src/operations.rs:2836-2845) lists only an unset auto key and generated columns in RETURNING, and a JSON column cannot be generated, so the ticket's "server-defaulted JSON column through save()" has no reachable door. Testing _hydrated_returning, the one function save() routes RETURNING values through (src/ferro/models.py:565), directly is the closest pin available.
  • Decision rule: I ran _json_needs_validation over the ticket's list. True for TypedDict, NamedTuple, dataclass, frozenset, date, time, bytes, Enum, list[Enum], dict[Enum, int], Optional[list[Model]] and dict[str, list[dict[str, Model]]]. False for dict[str, int], list[str], dict, list, Any, typing.Dict, typing.List[int] and Optional[dict[str, Any]].
  • Docs: Dimensions sits inside the program snippet the page embeds first. The "dimensions" JSON, DDL line is asserted against create_tables()'s statements in main(), and test_docs_examples.py -k models_and_fields is green.

Quality: PASS

  • Minor · [AGENTS.md#I-2] · src/ferro/columns.py:237 at c4b7040 · if origin is Literal: return False treats every Literal as native, including an enum-member literal. lits: list[Literal[E.A]] = [] registers no converter, and on both dialects it hydrates as ['a'] where a constructed instance holds [E.A]. The ticket's own rule is "an Enum ... at any nesting". Fix: treat a Literal as native only when all of its arguments are str/int/bool/None.

Verify: PR head c4b7040 passed (ticket Verify block on SQLite + Postgres 18.4: 130 passed; docs example 2 passed; docs lint clean; tests/test_structural_types.py 20 passed)
Trial merge: not run (base unchanged; merge base is origin/main cf1ee43)

…d Any-valued columns (#737)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: c4b7040..114b7826

  • Minor (no Postgres json pin) · ADDRESSED · tests/test_json_field_hydration.py:42 at 114b782 · JShade.plain uses db_type="json" and round-trips in test_nested_model_hydrates_as_the_model on both dialects (line 121).
  • Minor (no Any-valued native pin) · ADDRESSED · tests/test_json_field_hydration.py:61-63 at 114b782 · dict[str, Any] and list[Any] (plus list[Literal["a", "b"]]) are in JNative, which is asserted to have an empty registry, and they load unchanged (lines 174-176).
  • Minor (enum-member Literal treated as plain JSON) · ADDRESSED · src/ferro/columns.py:237-239 at 114b782 · any(isinstance(a, Enum) ...) now registers a converter for list[Literal[Tone.WARM]]. test_literal_of_an_enum_member_hydrates_as_the_member checks type(...) is Tone, which a plain 'warm' string would fail even though the StrEnum compares equal to it.

No new Critical or Important breakage in the fix diff.

Spec: PASS

Cannot verify: none

Quality: PASS

Verify: PR head 114b7826 passed (ticket Verify block on SQLite + Postgres 18.4: 132 passed, 8 of them Postgres runs of the new file; docs example 2 passed; docs lint clean; tests/test_structural_types.py 20 passed)
Trial merge: not run (base unchanged; merge base is origin/main cf1ee43)

0x054 added a commit that referenced this pull request Oct 30, 2026
`tests/test_generate_generated_columns.py::test_drift_says_nothing_of_a_policy_carried_around_a_replace[postgres]`
failed on both Postgres shards of PR #832's CI with `ValueError: too
many values to unpack (expected 1, got 2)` at the `[(using,)] =
policies` line.

**Why:** the `retyped_by_hand` helper reads `pg_policy` by policy name
with no table filter. Every Postgres test runs in its own schema, so
under xdist two tests can hold a `transaction` table carrying
`rls_transaction_direction` at the same moment, and the lookup returns
both. The `pg_attrdef` query right above it was already scoped through
`'"transaction"'::regclass`; main's run on cf1ee43 passed on timing
alone.

**Fix:** `WHERE polrelid = '"transaction"'::regclass AND polname = ...`,
the same scoping the neighbouring query uses. No other unscoped
`pg_policy` read exists in `tests/`.

**Verify:** `uv run --no-sync pytest
tests/test_generate_generated_columns.py -k "carried or retyped or
policy" --db-backends=postgres -q` → 5 passed on Postgres 18.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@0x054
0x054 merged commit e1d49a4 into main Oct 30, 2026
11 checks passed
@0x054
0x054 deleted the sdd/737-json-family-fields-hydrate-as-declared-type branch October 30, 2026 19:25
@cursor cursor Bot mentioned this pull request Nov 2, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON-family fields hydrate as the raw JSON value, not the declared type (nested models, sets, tuples, datetimes, UUIDs)

1 participant