Repository navigation
fix: JSON-family fields hydrate as their declared type (#737) - #832
Merged
Merged
Conversation
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>
6 tasks
…ields-hydrate-as-declared-type
0x054
commented
Oct 30, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
Spec: PASS
- Minor · [ticket#Acceptance criteria] · tests/test_json_field_hydration.py:39 at c4b7040 · Missing · the Postgres runs exercise only the derived
jsonbcolumn; no model declaresdb_type="json", so the "Postgres (JSONandjsonb)" half of the first criterion has no test. A probe I ran (nested model underdb_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: Anyis refused at connect withunknown logical_type 'unknown'), butdict[str, Any]andlist[Any]are valid JSON columns that reach theAnybranch of_json_needs_validation, andJNativedeclares neither · the ticket listsAnyamong the native annotations
Cannot verify: none
Checked and accepted:
tests/test_structural_types.pyis 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 inRETURNING, and a JSON column cannot be generated, so the ticket's "server-defaulted JSON column throughsave()" has no reachable door. Testing_hydrated_returning, the one functionsave()routes RETURNING values through (src/ferro/models.py:565), directly is the closest pin available. - Decision rule: I ran
_json_needs_validationover the ticket's list. True for TypedDict, NamedTuple, dataclass,frozenset,date,time,bytes,Enum,list[Enum],dict[Enum, int],Optional[list[Model]]anddict[str, list[dict[str, Model]]]. False fordict[str, int],list[str],dict,list,Any,typing.Dict,typing.List[int]andOptional[dict[str, Any]]. - Docs:
Dimensionssits inside theprogramsnippet the page embeds first. The"dimensions" JSON,DDL line is asserted againstcreate_tables()'s statements inmain(), andtest_docs_examples.py -k models_and_fieldsis green.
Quality: PASS
- Minor · [AGENTS.md#I-2] · src/ferro/columns.py:237 at c4b7040 ·
if origin is Literal: return Falsetreats everyLiteralas 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 "anEnum... at any nesting". Fix: treat aLiteralas native only when all of its arguments arestr/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
commented
Oct 30, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
Re-review: c4b7040..114b7826
- Minor (no Postgres
jsonpin) · ADDRESSED · tests/test_json_field_hydration.py:42 at 114b782 ·JShade.plainusesdb_type="json"and round-trips intest_nested_model_hydrates_as_the_modelon both dialects (line 121). - Minor (no
Any-valued native pin) · ADDRESSED · tests/test_json_field_hydration.py:61-63 at 114b782 ·dict[str, Any]andlist[Any](pluslist[Literal["a", "b"]]) are inJNative, which is asserted to have an empty registry, and they load unchanged (lines 174-176). - Minor (enum-member
Literaltreated as plain JSON) · ADDRESSED · src/ferro/columns.py:237-239 at 114b782 ·any(isinstance(a, Enum) ...)now registers a converter forlist[Literal[Tone.WARM]].test_literal_of_an_enum_member_hydrates_as_the_membercheckstype(...) is Tone, which a plain'warm'string would fail even though theStrEnumcompares 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>
…ields-hydrate-as-declared-type
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;dictwith non-string keys) now hydrates as its declared type.ColumnSpec.json_adapterholds the field'sTypeAdapter, 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 nestedDimensionsfield 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,postgresfailed before the change (12 failed, 5 passed: dict/list/str values instead of declared types, nojsonentries 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]) withmodel_dump_json()underwarnings=error, NULL optional model, native fields (no registry entry, values unchanged),Rowprojection, same-session refresh, RETURNING (_hydrated_returningdirectly: no JSON column can be generated, so no write returns one today), and a stored document missing a key raisingFailed to hydrate json field JShade.dimensions.Anyis not a column type in Ferro (unknown logical_type), so the ticket'sAnycase is covered by_json_needs_validationtreating it as native, not by a model.Interfaces
Interfaces changed: none (
ColumnSpec.json_adapter: TypeAdapter[Any] | None, excluded fromColumnSpecequality and repr;("json", adapter.validate_python)in the registry).Laws
__init__); native fields keep the zero-copy pathFailed to hydrate json field ...; no pass-throughmodels_and_fields.pyand its_annotatedtwinNotes for the reviewer
Outside Files owned:
tests/test_structural_types.py: two assertions pinned the bug ("reload yields dicts" forlist[BaseModel],entries == [e.model_dump() ...]); they now assert the models.Merged
origin/main(cf1ee43), which carries thetest_postponed_annotations.pyfix (#831); Verify re-run on the merged tree.The docs example's
DDLstring gained"dimensions" JSON,;main()asserts it against the statementscreate_tables()ran, andtest_docs_examples.pyis green.Full local suite (xdist, SQLite + Postgres 18): before the merge, only the two items above failed.
🤖 Generated with Claude Code