fix(arrow/array): Parity with Arrow C++ when reading/writing null to/from JSON - #833
fix(arrow/array): Parity with Arrow C++ when reading/writing null to/from JSON#833serramatutu wants to merge 13 commits into
null to/from JSON#833Conversation
null to/from JSONnull to/from JSON
|
Is it possible for us to do this without a breaking change? I'd rather avoid the breaking change of changing a public API method if at all possible. |
b8f92fa to
d15839d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
arrow/array/record.go:424
- The UnmarshalOne docstring says it “receives an already-configured json.Decoder, so options such as UseNumber set by the caller are honored”, but the implementation now buffers values as RawMessage and re-decodes them with a fresh decoder that always calls UseNumber. This no longer honors caller decoder configuration (and in particular forces UseNumber even if the caller didn’t enable it).
// UnmarshalOne reads one row (a JSON object) from the supplied decoder and
// appends a value to each field in the RecordBuilder.
//
// Unlike UnmarshalJSON, this method receives an already-configured
// json.Decoder, so options such as UseNumber set by the caller are honored
// for nested field decoding. This is critical for preserving large integer
// values (>2^53) that cannot be represented exactly as float64.
|
@serramatutu are you still working on this? |
|
@zeroshade I haven't had the time to push this forward since I last worked on it. Currently on PTO, will be back in July. |
d15839d to
9363b79
Compare
|
@zeroshade I addressed all the issues in new commits and rebased onto main. I don't have permissions to request another Copilot review. I did run a Claude Code review locally and it says it's good. There is one issue that I found where |
36b5d5b to
718a3bf
Compare
…#995) ### Rationale for this change I found a memory leak while working on the [JSON nullable parity PR](#833). It's triggered whenever a non-empty builder gets resized to zero. This memory leak can be achieved with public APIs only (`Append` and `Resize`), meaning it's an actual bug in the public API and not a misuse of internal APIs where I didn't check for an invariant. In the specific case of the JSON PR, whenever we found an error in the first row that would call `Resize(-1)`, which in turn calls `Resize(0)` on all the previous fields, and the bug gets triggered. You can checkout to each commit to see the test failing then passing after the fix. ### What changes are included in this PR? - A new unit test to repro the memory leak. - The fix: always respect `minBuilderCapacity` when calling `Resize()` ### Are these changes tested? Yes. ### Are there any user-facing changes? No.
This commit introduces 2 changes to `RecordBuilder.UnmarshalOne()`: - Now it checks for integrity first and only appends to internal builders last. This makes it possible to get an error when deserializing one JSON row without corrupting the state of the builder. I.e it is possible to get an error for a specific row and keep building the batch without that row. - When dealing with required fields, it checks for explicit `null`s in the input, and for missing required fields. This is in line with Arrow C++.
718a3bf to
859fbe7
Compare
|
@zeroshade I've rebased this onto the latest main and the tests are now passing :) |
Rationale for this change
I raised an issue at the Arrow Community meeting regarding how Arrow Go allows the user to construct invalid record batches by reading JSON with nulls in it even if the schema says it's not null. It is also possible to construct invalid batches by calling
AppendNull()in the builders, and currently there is no way to validate that. I was instructed to look at how Arrow C++/PyArrow do it, and replicate it here.TL;DR:
nullif the parent field is non-nullableHere's a link to my full investigation, including the code so you can run it yourself: https://github.com/serramatutu/arrow-internal-nulls
What changes are included in this PR?
You can review commit by commit.
To address Issue 1:
RecordBuilder.UnmarshalOne()return an error if anullis present for a non-nullable field, or if the value is not given.StructBuilder.UnmarshalOne(), so that it works recursively.RecordBuilder.UnmarshalOne()now validates first and appends later, so that even if it needs to raise an error, the user can keep trying to read new records without compromising the state of the reader. Before it could be the case that it would only partially append a row, leaving the builder in an undefined state before reading the next row. I didn't do the validate then append change forStructBuildersince it relies onfield.UnmarshalOne(), and there is no way of unmarshaling without appending. This is a TODO for improvement.To address Issue 2:
dtype.Field(i).Nullablein theStructandRecordserializers to encode things asnilifarr.IsNull(i).GetOneForMarshal()implementations do aif a.IsNull(I { return nil }check, so this is somewhat pointless. I could changeGetOneForMarshal()to mean "get one for marshal assuming it's non-nullable", but it changes one of the invariants of using this public method.I had to change a bunch of tests that implicitly depended on fields being nullable, even if they didn't declare the fields as such.
Are these changes tested?
Yes.
Are there any user-facing changes?
Yes, this makes the JSON decoder less lenient and validation stricter. So this could be a breaking change for users who are relying on the leniency of the JSON decoder.