-
Notifications
You must be signed in to change notification settings - Fork 139
fix(arrow/array): Parity with Arrow C++ when reading/writing null to/from JSON
#833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
serramatutu
wants to merge
22
commits into
apache:main
Choose a base branch
from
serramatutu:serramatutu/JSON-nulls-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
a026359
`RecordBuilder.UnmarshalOne()` checks for non-nullable fields
serramatutu 379e86f
`StructBuilder.UnmarshalOne()` checks for non-nullable fields
serramatutu 115d4f6
Make struct and record respect `field.Nullable` when serializing
serramatutu 7353862
Add stricter tests to null JSON in record and struct
serramatutu 5743041
Fix tests that were implicitly depending on wrong nullable semantics
serramatutu c576f1b
Fix invalid JSON literal in RecordBuilder test
serramatutu 4f771fb
Fix unreachable nullable-field branch in Struct.GetOneForMarshal
serramatutu 81295cb
Validate before appending in StructBuilder.UnmarshalOne
serramatutu 7e6570b
Rely on builder checkpoints to discard failed RecordBuilder rows
serramatutu 83e5d59
Make `StructBuilder` error when required field is not given
serramatutu 138fdcb
Declare nullable fields in rollback tests that decode null
serramatutu 0946848
Roll back StructBuilder rows with builder checkpoints
serramatutu a93bb86
Check FromJSON error before releasing the roundtripped array
serramatutu 59f1711
Add `unmarshalListValues`, check the schema of JSON list items
serramatutu e58088c
add `unmarshalChild` helper and make list builder use it
serramatutu cc6ea82
Make map builder use validating list builder
serramatutu 42cfc61
Make union builder respect inner nullability
serramatutu 24a5e49
Make REE builder respect nullability
serramatutu 515b176
Add checkpoints to standalone builders
serramatutu 2efa1f6
Fix broken assertion
serramatutu 8d37a55
Fix performance issues with JSON decoding
serramatutu b2595af
Collapse nested field builder into `nestedJSONDecoder`
serramatutu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,8 @@ | |
| package array | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "errors" | ||
| "fmt" | ||
| "math/bits" | ||
| "sync/atomic" | ||
|
|
@@ -388,6 +390,170 @@ func (b *builder) UnsafeAppendBoolToBitmap(isValid bool) { | |
| b.length++ | ||
| } | ||
|
|
||
| var jsonNull = []byte("null") | ||
|
|
||
| func unmarshalChild(dec *json.Decoder, child Builder, field arrow.Field) error { | ||
| if field.Nullable { | ||
| return child.UnmarshalOne(dec) | ||
| } | ||
|
|
||
| nulls := child.NullN() | ||
| if nulls == UnknownNullCount { | ||
| var val json.RawMessage | ||
| if err := dec.Decode(&val); err != nil { | ||
| return err | ||
| } | ||
| return unmarshalBufferedChild(val, child, field) | ||
| } | ||
|
|
||
| // Every builder appends a null for a JSON null and for nothing else, so | ||
| // the child's null count going up by one over the call means the input | ||
| // had a null. | ||
| length := child.Len() | ||
| if err := child.UnmarshalOne(dec); err != nil { | ||
| return err | ||
| } | ||
| if child.Len() == length+1 && child.NullN() == nulls+1 { | ||
| return fmt.Errorf("field '%s' is non-nullable but got null", field.Name) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func unmarshalBufferedChild(val json.RawMessage, child Builder, field arrow.Field) error { | ||
| if !field.Nullable && bytes.Equal(val, jsonNull) { | ||
| return fmt.Errorf("field '%s' is non-nullable but got null", field.Name) | ||
| } | ||
|
|
||
| valDec := json.NewDecoder(bytes.NewReader(val)) | ||
| valDec.UseNumber() | ||
| return child.UnmarshalOne(valDec) | ||
| } | ||
|
|
||
| // rowDecoder decodes a value out of a reused copy of that value, so that | ||
| // unescaping a string costs O(value) instead of O(remaining document). | ||
| // | ||
| // NOTE: goccy/go-json unescapes in place and shifts every byte after the escape, | ||
| // which is a big performance cost for large JSON documents: | ||
| // https://github.com/goccy/go-json/blob/v0.10.6/internal/decoder/string.go#L190 | ||
| type rowDecoder struct { | ||
| buf json.RawMessage | ||
| reader bytes.Reader | ||
| scratch json.RawMessage | ||
| } | ||
|
|
||
| func (r *rowDecoder) next(dec *json.Decoder) (*json.Decoder, error) { | ||
| if err := dec.Decode(&r.buf); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| r.reader.Reset(r.buf) | ||
| rowDec := json.NewDecoder(&r.reader) | ||
| rowDec.UseNumber() | ||
| return rowDec, nil | ||
| } | ||
|
|
||
| func (r *rowDecoder) skip(dec *json.Decoder) error { | ||
| return dec.Decode(&r.scratch) | ||
| } | ||
|
|
||
| // fieldContainer is what nestedJSONDecoder needs out of *arrow.Schema and | ||
| // *arrow.StructType. | ||
| type fieldContainer interface { | ||
| NumFields() int | ||
| Field(i int) arrow.Field | ||
| } | ||
|
|
||
| // nestedJSONDecoder decodes JSON objects into one builder per field. | ||
| type nestedJSONDecoder struct { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added this as a way to reuse code between |
||
| rowDecoder | ||
|
|
||
| fields fieldContainer | ||
| seen []bool | ||
| fieldIdx map[string]int | ||
| } | ||
|
|
||
| func newNestedJSONDecoder(fields fieldContainer) nestedJSONDecoder { | ||
| return nestedJSONDecoder{fields: fields} | ||
| } | ||
|
|
||
| func (d *nestedJSONDecoder) fieldIndexByName(name string) (int, bool) { | ||
| if d.fieldIdx == nil { | ||
| d.fieldIdx = make(map[string]int, d.fields.NumFields()) | ||
| for i := 0; i < d.fields.NumFields(); i++ { | ||
| d.fieldIdx[d.fields.Field(i).Name] = i | ||
| } | ||
| } | ||
| idx, ok := d.fieldIdx[name] | ||
| return idx, ok | ||
| } | ||
|
|
||
| // unmarshalFields consumes one object from the decoder and appends its fields to | ||
| // each nested field builder. It consumes the opening and closing '{' and '}'. | ||
| // | ||
| // Nullable fields that are not present in the JSON-object are assumed to be null. | ||
| func (d *nestedJSONDecoder) unmarshalFields(dec *json.Decoder, builders []Builder) error { | ||
| // grow the "seen" buffer if needed | ||
| if cap(d.seen) < d.fields.NumFields() { | ||
| d.seen = make([]bool, d.fields.NumFields()) | ||
| } else { | ||
| d.seen = d.seen[:d.fields.NumFields()] | ||
| clear(d.seen) | ||
| } | ||
|
|
||
| for dec.More() { | ||
| keyTok, err := dec.Token() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| key, ok := keyTok.(string) | ||
| if !ok { | ||
| return errors.New("JSON row must be an object with keys and values, but key is missing") | ||
| } | ||
|
|
||
| idx, ok := d.fieldIndexByName(key) | ||
| if !ok { | ||
| // TODO(serramatutu): this is equivalent to ParseOptions::Ignore in Arrow C++, i.e silently drop extra keys. | ||
| // We should eventually support ParseOptions::InferType and ParseOptions::Error | ||
| if err := d.skip(dec); err != nil { | ||
| return err | ||
| } | ||
| continue | ||
| } | ||
|
|
||
| if d.seen[idx] { | ||
| return fmt.Errorf("key '%s' is specified twice", key) | ||
| } | ||
| d.seen[idx] = true | ||
|
|
||
| if err := unmarshalChild(dec, builders[idx], d.fields.Field(idx)); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| // consume the closing '}' | ||
| if _, err := dec.Token(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // check that all non-nullable fields were specified | ||
| for i := 0; i < d.fields.NumFields(); i++ { | ||
| field := d.fields.Field(i) | ||
| if !d.seen[i] && !field.Nullable { | ||
| return fmt.Errorf("field '%s' is required but no value was given", field.Name) | ||
| } | ||
| } | ||
|
|
||
| // missing fields are nullable at this point, so they get a null | ||
| for i := 0; i < d.fields.NumFields(); i++ { | ||
| if !d.seen[i] { | ||
| builders[i].AppendNull() | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func NewBuilder(mem memory.Allocator, dtype arrow.DataType) Builder { | ||
| // FIXME(sbinet): use a type switch on dtype instead? | ||
| switch dtype.ID() { | ||
|
|
||
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
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
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
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to add this due to bad performance from goccy when escaping newlines...