fix: refuse a dict whose keys collide as JSON instead of losing a value - #1943
fix: refuse a dict whose keys collide as JSON instead of losing a value#1943shcheklein wants to merge 1 commit into
Conversation
Deploying datachain with
|
| Latest commit: |
16b4317
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2d4f832e.datachain-2g6.pages.dev |
| Branch Preview URL: | https://fix-reject-ambiguous-dict-ke.datachain-2g6.pages.dev |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
936ed82 to
38388e8
Compare
7326622 to
55306ed
Compare
84ad41a to
55306ed
Compare
ac7f34e to
b608559
Compare
There was a problem hiding this comment.
Pull request overview
Adds write-time detection for Python dictionary keys that collide when serialized as JSON, preventing silent data loss.
Changes:
- Detects duplicate JSON property names and raises column-aware errors.
- Validates nested dictionary arrays using backend encoder output.
- Adds collision, round-trip, backend, and known-gap tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/datachain/data_storage/warehouse.py |
Implements collision-safe JSON serialization. |
tests/unit/test_warehouse.py |
Tests conversion and backend serialization behavior. |
tests/unit/lib/test_datachain.py |
Tests end-to-end save behavior and known gaps. |
Suppressed comments (10)
src/datachain/data_storage/warehouse.py:162
- The SQLite adapter does not call
_dump_json; it callsdatachain.json.dumpsindependently. State that the options currently match rather than claiming this helper is shared, otherwise future maintainers may incorrectly assume adapter changes automatically update validation.
JSON columns store this text. Array items are instead stored as they are
and serialized later, by sqlite3's registered adapter, which calls this
same function -- so dumping here shows what that will write.
tests/unit/test_warehouse.py:352
- AGENT.md:109-112 requires tests to express intent through their names and omit test docstrings/comments. Remove this historical explanation; the parameter IDs and test name already identify the covered collision.
"""The keys an object array materializes are created by the encoder, after
_to_jsonable has run. Counting keys let one of these cancel out: the collision
removed a key and the array added one, so the totals matched.
"""
tests/unit/test_warehouse.py:361
- AGENT.md:109-112 requires tests to rely on descriptive names rather than test docstrings. The test name and exception assertion already state this contract, so remove the docstring.
"""The check runs the driver's encoder, so it sees the driver's TypeError first.
It must arrive named rather than as a bare TypeError from the conversion.
"""
tests/unit/test_warehouse.py:374
- AGENT.md:109-112 says tests should use clear names and skip docstrings/comments. This test name plus the SQLite-only marker captures the behavior, so remove the explanatory docstring.
"""sqlite3's registered adapt_array spells a tuple key "(1, 2)", so this does
not collide there. Scoped to SQLite because that adapter is the subject: on
ClickHouse a JSON item resolves to str, so dict items never take this path.
_to_jsonable spells it "[1,2]", which collides with the string key and had
this valid value rejected.
tests/unit/test_warehouse.py:393
- AGENT.md:109-112 requires tests to omit explanatory docstrings and rely on their names. The test name and parameter IDs already distinguish the driver-collision cases, so remove this docstring.
"""The mirror: these do collide in the driver's spelling. _to_jsonable spells
them "[1,2]" and '"2020-01-02"' — both distinct from the string key — so the
JSON-encoded spelling let the value be lost.
tests/unit/test_warehouse.py:404
- AGENT.md:109-112 requires tests to use descriptive names instead of test docstrings/comments. The test name and backend-conditional assertions already encode this invariant, so remove the docstring.
"""Dict items are checked for colliding keys but never re-encoded, so each
backend keeps the form it already stored. Re-encoding them on SQLite stops
equality, distinct and merge from matching rows written earlier. ClickHouse
resolves a JSON column to String and has always stored strings there.
"""
tests/unit/lib/test_datachain.py:1024
- AGENT.md:109-112 requires test intent to be conveyed by the test name rather than a docstring. This name and the NaN/Inf parameter IDs are sufficient, so remove the docstring.
"""These survive a round trip, which reading pydantic's JSON instead of its
dump would have written as null.
"""
tests/unit/lib/test_datachain.py:1040
- AGENT.md:109-112 requires tests to omit explanatory docstrings/comments. The descriptive test name and SQLite-only marker establish the contract; remove this docstring.
"""Guards SQLite's stored form: encoding the items as JSON strings makes this
filter stop matching. ClickHouse stores strings already and cannot express the
comparison at all — a dict literal collides with its {name:Type} substitution
syntax and the query fails to parse.
"""
tests/unit/lib/test_datachain.py:1065
- AGENT.md:109-112 says tests should rely on clear names and skip comments. This restates the test name and expected exception, so remove it.
# both keys become the JSON key "1", so one value would vanish with nothing
# on the read side able to recover it
tests/unit/lib/test_datachain.py:1078
- AGENT.md:109-112 says tests should rely on their names and assertions rather than explanatory comments. The following assertions already show that both keys return as strings, so remove this comment.
# both entries survive, but the declared int key reads back as a JSON string
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
Suppressed comments (3)
src/datachain/data_storage/warehouse.py:122
- This comment describes the reverted
model_dump_json()approach, but the code returnsmodel_dump(mode="json"); the strict xfail intest_datachain.pyconfirms that this path has already merged colliding keys. Please document the actual limitation rather than claiming the JSON output is read here.
# Pydantic decides how its own keys are written, and model_dump merges
# any that collide. The JSON it writes keeps both, so read that: it
# settles the datetime and Decimal spellings too, which the encoder
# here does not reproduce.
tests/unit/lib/test_datachain.py:1067
- This single-row case does not cover a collision discovered after earlier rows have been buffered or flushed. The PR description says the 2,000-row poisoned-save case was manually verified, and AGENT.md:121-129 requires such probes to become permanent tests; add that regression and assert that the failed save exposes no partial dataset or rows.
# both keys become the JSON key "1", so one value would vanish with nothing
# on the read side able to recover it
with pytest.raises(UdfError, match="would be lost"):
chain.save("ambiguous_keys")
tests/unit/test_warehouse.py:316
- This test name already states the invariant, while the docstring records a discarded key-counting implementation. AGENT.md:99-110 says tests should rely on clear names and omit change-narrative docstrings; remove it.
"""serialize_numpy materializes an object array into mappings during encoding,
so the emitted JSON holds more keys than the input. Nothing collides, so this
must store: counting keys before and after would have called it a collision.
"""
| "dict_rows" | ||
| ) | ||
|
|
||
| assert saved.filter(C("x.rows") == [{"a": 1}]).count() == 1 |
There was a problem hiding this comment.
I checked this before writing the tests, and the four extra operations would not catch the regression they are meant to guard.
Within one test run every dataset is written by the same code, so distinct/group_by/subtract/merge compare stored against stored and agree with themselves under either representation. Applying the exact change they are supposed to catch (storing array items as JSON strings):
| operation | correct | with JSON-string storage |
|---|---|---|
distinct |
1 | 1 |
group_by |
1 | 1 |
subtract |
0 | 0 |
merge right side |
present | present |
filter == literal |
2 | 0 |
Only the filter test moves, because it is the one that compares stored data against a Python literal. Adding the other four would give four tests that pass under the very regression they name — the vacuous-test failure AGENT.md:130-133 warns about.
The manual checks in the description did show those operations breaking, but only across generations: a dataset written before the change compared against one written after. A single test run cannot produce that without a fixture of pre-encoded rows.
That fixture is buildable — insert a row with the old representation via raw SQL, then assert distinct/merge treat it as equal to a normally written one. It would genuinely catch a representation change, at the cost of being SQLite-only and pinning a byte format in a test. I have not added it, and I would rather you decide whether that trade is worth it than have me pick unilaterally. Leaving this thread open for that.
b608559 to
6dc1c72
Compare
6dc1c72 to
e83a2f7
Compare
…ady are
_flatten_fields_values dispatches on the runtime type of a field's value and
only knows list and dict. A tuple falls through to `yield value`, and the dict
branch converts a value that is itself a model but not one that is a list of
them, so those reach the warehouse as live pydantic instances while the same
models in a list arrive as plain dicts.
That is visible in what gets stored. A list of models is written as an array of
objects; a tuple of the same models is written as an array of JSON strings,
because the warehouse converts each instance separately:
list[Item] [{"n":1,"label":"a"},{"n":2,"label":"b"}]
tuple[Item, ...] ["{\"n\":1,\"label\":\"a\"}","{\"n\":2,\"label\":\"b\"}"]
Both spellings still read back as the declared type, so datasets written before
this change are unaffected; the two simply agree now.
The same gap keeps a mapping with colliding keys out of reach of the write-time
check in #1943. A dict that arrives inside a live model has already been through
model_dump, which merges keys that serialize to the same JSON name, while the
same dict inside a list arrives whole. Flattening them the same way lets the
existing check see both.
…ady are
_flatten_fields_values dispatches on the runtime type of a field's value and
only knows list and dict. A tuple falls through to `yield value`, and the dict
branch converts a value that is itself a model but not one that is a list of
them, so those reach the warehouse as live pydantic instances while the same
models in a list arrive as plain dicts.
One recursive helper now walks every collection element by element. The list
path used to decide from the first element whether the whole collection held
models, which a fixed-length tuple breaks: tuple[Item, int] would have called
model_dump on the int, and tuple[int, Item] would have left the model alone.
That is visible in what gets stored. A list of models is written as an array of
objects; a tuple of the same models is written as an array of JSON strings,
because the warehouse converts each instance separately:
list[Item] [{"n":1,"label":"a"},{"n":2,"label":"b"}]
tuple[Item, ...] ["{\"n\":1,\"label\":\"a\"}","{\"n\":2,\"label\":\"b\"}"]
Both spellings still read back as the declared type, so datasets written before
this change are unaffected; the two simply agree now.
The same gap keeps a mapping with colliding keys out of reach of the write-time
check in #1943. A dict that arrives inside a live model has already been through
model_dump, which merges keys that serialize to the same JSON name, while the
same dict inside a list arrives whole. Flattening them the same way lets the
existing check see both.
JSON names are strings, so distinct Python keys can collapse onto one when a dict is serialized. `1` and `"1"` both become `"1"`, the last one wins, and the row is written with a value missing. The read side has nothing left to recover it from. Each writer is checked by reading what it produced, not by restating what it would produce. Earlier attempts predicted the spelling and drifted from it in both directions, refusing valid data and missing real losses. _to_jsonable builds the dict itself, JSON-encoding non-str keys so key_needs_json_decode can read them back, and a collision there merges the values before anything is serialized. So it checks as it builds. Values stored unconverted are spelled by the driver's own encoder, which also materializes object arrays into mappings the input never held. Neither is predictable from the input, so the emitted JSON is parsed with a duplicate-preserving object_pairs_hook. convert_type calls that before storing an array of dicts, so the failure names the column and arrives before the insert rather than from inside sqlite3's adapter mid-batch. Two shapes are left for #1914, pinned by a strict xfail. A live pydantic instance has its colliding keys merged inside model_dump(mode="json"), before anything here can see them. model_dump_json keeps both, but reading it means serializing twice: that re-runs field serializers, which drains a one-shot iterator and lets a stateful one write different keys than were checked. It also writes nan and the infinities as null, which model_dump preserves. Closing this needs a single-pass hook pydantic does not offer. Also deferred there: _numpy_to_python rebuilds a mapping while materializing an object array, so np.datetime64("NaT", "ns") and None collapse onto one None key before any emitted JSON exists to read.
e83a2f7 to
16b4317
Compare
Refs #1914 — two shapes are left, both pinned by strict
xfail; see Known gaps.Warning
Draft — not ready to merge. The write-time check works, but it leans on
markers and backend-specific assertions that should not land as they are.
TODO before this is mergeable
Three xfails and five SQLite-only tests. Each is a real gap, not a formality:
tuple[Model, ...]anddict[str, list[Model]]reach the converter asmodel instances, and
model_dump(mode="json")merges the keys insidePydantic before anything here can look. Closing this needs models converted
in Pydantic's python mode everywhere, which preserves both keys — and that
needs this project's encoder to first cover the types only JSON mode knows
(
timedelta, paths, addresses, plain enums, sets), or those regress._numpy_to_pythonrebuilds the mapping while materializing the array, sonp.datetime64("NaT", "ns")andNoneland on one key. This is our owncode and the fix belongs where the merge happens.
skip_if_not_sqlitetests. They assert what SQLite's adapterwrites, which has no ClickHouse equivalent — there a JSON item resolves to
str, so dict items never take that path. They should assert a relationshipthat holds on both backends rather than a physical form. The pattern works:
one test already compares two shapes' stored values instead of naming either.
Until those are addressed this documents a defect rather than fixing it, and the
markers would become permanent furniture in the test suite.
The problem
JSON names are strings, so distinct Python keys can collapse onto one when a
dictis serialized.1and"1"both become"1", the last one wins, and the row is written with a value missing — silently, with nothing on the read side able to recover it.The shape of the fix
Every earlier revision of this PR failed the same way: it tried to predict what a serializer would write. Each prediction drifted from the real thing in both directions — refusing valid data and missing real losses. What works is reading what the writer actually produced.
_to_jsonablechecks as it builds. It constructs the dict itself, JSON-encoding non-strkeys sokey_needs_json_decodecan read them back. A collision there merges the values before anything is serialized, so there is no later output to inspect.Everything stored unconverted is checked from the emitted JSON. Those values are spelled by the driver's own encoder —
sqlite3's registeredadapt_array, which isdatachain.json.dumps. It spells a tuple key(1, 2)where_to_jsonablespells it[1,2], and withserialize_numpyit materializes object arrays into mappings the input never contained. Neither is predictable, so the emitted JSON is parsed with a duplicate-preservingobject_pairs_hook.convert_typecalls that before storing an array of dicts. Without it the guard would not fire untilsqlite3adapted the value mid-insert, with no column to name:Verified that a poisoned row in the middle of a 2000-row save leaves nothing written.
80 source lines in one file: an exception, a hook, and one
_dump_jsonshared by both branches.Known gaps
Both are pre-existing, both stay on #1914, and both are pinned by strict
xfailso they convert to failures when fixed.A live pydantic instance.
model_dump(mode="json")merges colliding keys inside pydantic-core, before anything here can see them. This is reachable fortuple[Model, ...]anddict[str, list[Model]], where the value arrives as a model rather than flattened. (read_valuesflattensModelandlist[Model]to plain dicts first, so those are caught, by_to_jsonable's own check.)I attempted this and reverted it.
model_dump_json()keeps both keys and emits a duplicate property, so reading that JSON detects the collision — but it is a second serialization pass, and that re-runs field serializers:A stateful serializer could likewise emit safe keys during the check and colliding ones during storage.
model_dump_jsonalso writesnanand the infinities asnull, whichmodel_dumppreserves. Closing this needs a single-pass hook pydantic does not offer, so it is deferred rather than approximated.A numpy object array.
_numpy_to_pythonrebuilds a mapping while materializing the array, sonp.datetime64("NaT", "ns")andNonecollapse onto oneNonekey there, before any emitted JSON exists to read. Other numpy-created mappings are covered, since the encoder builds them where the emitted JSON can be read.Why detect the collision rather than reject the annotation
The issue suggested refusing ambiguous key annotations at schema-build time. Detecting the actual collision is better on three counts:
dict[str | int, str]holding{"x": "a", 2: "b"}keeps working. Rejecting the annotation would break it.strsubclass whose__eq__hides a duplicate has a perfectly ordinary annotation.For the record:
is_chain_typealready refusesdict[str | int, X]. The loss was reachable because model fields never consult it — the gap tracked in #1942.Cost
Not measurable where it matters. A saved row costs ~240 µs and these differences are single-digit µs; across repeated end-to-end runs
mainand this branch are indistinguishable. In isolation,convert_typeon a 20-elementlist[dict]goes 0.74 → 7.7 µs. Element types other thandictare untouched.Validation
Rebased onto
bc3e2c83.tests/unit: 3178 passed, 15 skipped, 18 xfailed.tests/func/test_datachain.py+test_udf.py+test_datasets.py: 449 passed, 117 skipped — one flake intest_udf_parallel_worker_failure_exits_peers, which passes on rerun and fails the same way onmain. ruff 0.16.4 and mypy 2.3.0 clean, matching the pins in.pre-commit-config.yaml.Tests go through
convert_typeor the chain API. Coverage includes the collision (int/str,None/"null",bool/"true", tuple/str,strsubclass,str-mixin enum), the four nested-collection shapes, the encoder's key spelling in both directions, the numpy-created mapping that is caught, the flattened model shapes, and a round trip ofnan/inf/-infthroughsave.Five tests carry
skip_if_not_sqlite: they assert whatsqlite3's adapter stores, which has no ClickHouse equivalent — there a JSON item resolves tostr, so dict items never take that path. The one test that derives its expectation fromwarehouse.python_type(JSON())runs on both.🤖 Generated with Claude Code