serramatutu commented on code in PR #833:
URL: https://github.com/apache/arrow-go/pull/833#discussion_r3847192619


##########
arrow/array/record.go:
##########
@@ -434,49 +436,74 @@ func (b *RecordBuilder) UnmarshalOne(dec *json.Decoder) 
error {
                return fmt.Errorf("record should start with '{', not %s", t)
        }
 
-       keylist := make(map[string]bool)
+       // consume one row checking for duplicates and nulls
+       keylist := make(map[string]json.RawMessage)
        for dec.More() {
                keyTok, err := dec.Token()
                if err != nil {
                        return err
                }
 
                key := keyTok.(string)
-               if keylist[key] {
+               if _, ok := keylist[key]; ok {
                        return fmt.Errorf("key %s shows up twice in row to be 
decoded", key)
                }
-               keylist[key] = true
+
+               var val json.RawMessage
+               if err := dec.Decode(&val); err != nil {
+                       return err
+               }
 
                indices := b.schema.FieldIndices(key)
                if len(indices) == 0 {
-                       var extra interface{}
-                       if err := dec.Decode(&extra); err != nil {
-                               return err
-                       }
                        continue
                }
 
-               if err := b.fields[indices[0]].UnmarshalOne(dec); err != nil {
-                       return err
+               idx := indices[0]
+
+               if bytes.Equal(val, []byte("null")) && 
!b.schema.Field(idx).Nullable {
+                       return fmt.Errorf("field '%s' is non-nullable but got 
null", key)
                }
+
+               keylist[key] = val
        }
 
        // consume the closing '}'
        if _, err := dec.Token(); err != nil {
                return err
        }
 
+       // check that all non-nullable fields were specified
        for i := 0; i < b.schema.NumFields(); i++ {
-               if !keylist[b.schema.Field(i).Name] {
+               f := b.schema.Field(i)
+               if _, ok := keylist[f.Name]; !ok && !f.Nullable {
+                       return fmt.Errorf("field '%s' is required but no value 
was given", f.Name)
+               }
+       }
+
+       // At this point we know there are no integrity errors, so append 
values to the
+       // field builders in schema order.
+       for i := 0; i < b.schema.NumFields(); i++ {
+               val, ok := keylist[b.schema.Field(i).Name]
+               if !ok {
                        b.fields[i].AppendNull()
+                       continue
+               }
+
+               valDec := json.NewDecoder(bytes.NewReader(val))
+               valDec.UseNumber()
+               if err := b.fields[i].UnmarshalOne(valDec); err != nil {
+                       b.Resize(-1)

Review Comment:
   Fixed. `Resize(-1)` is gone from `RecordBuilder` — #1113 landed while this 
PR was open and gives exactly the pre-row checkpoint you asked for, so 
`UnmarshalOne` now relies on upstream's `builderCheckpoint` capture/restore. 
Nullability is also validated before anything is appended now, so the 
`{"a":1,"b":[2,"bad"]}` case is covered by upstream's rollback tests plus 
`TestRecordBuilderRejectsNullInNestedNonNullableField` and 
`TestRecordBuilderKeepsDecodingAfterRejectedRow` (malformed nested value, then 
a valid row).



##########
arrow/array/struct.go:
##########
@@ -494,40 +525,59 @@ func (b *StructBuilder) UnmarshalOne(dec *json.Decoder) 
error {
                                return errors.New("missing key")
                        }
 
-                       if keylist[key] {
+                       if _, dup := keylist[key]; dup {
                                return fmt.Errorf("key %s is specified twice", 
key)
                        }
 
-                       keylist[key] = true
+                       var next json.RawMessage
+                       if err := dec.Decode(&next); err != nil {
+                               return err
+                       }
 
-                       idx, ok := b.dtype.(*arrow.StructType).FieldIdx(key)
+                       idx, ok := dtype.FieldIdx(key)
                        if !ok {
-                               var extra interface{}
-                               if err := dec.Decode(&extra); err != nil {
-                                       return err
-                               }
                                continue
                        }
 
-                       if err := b.fields[idx].UnmarshalOne(dec); err != nil {
-                               return err
+                       if bytes.Equal(next, []byte("null")) && 
!dtype.Field(idx).Nullable {
+                               return fmt.Errorf("field '%s' is non-nullable 
but got null", dtype.Field(idx).Name)
+                       }
+
+                       keylist[key] = next
+               }
+
+               // consume '}'
+               if _, err := dec.Token(); err != nil {
+                       return err
+               }
+
+               // check that all non-nullable fields were specified
+               for _, field := range dtype.Fields() {
+                       if _, ok := keylist[field.Name]; !ok && !field.Nullable 
{
+                               return fmt.Errorf("field '%s' is required but 
no value was given", field.Name)
                        }
                }
 
-               // Append null values to all optional fields that were not 
presented in the json input
-               for _, field := range b.dtype.(*arrow.StructType).Fields() {
-                       if !field.Nullable {
+               // All validation passed; append the struct entry and its child 
values.
+               b.Append(true)
+               for i, field := range dtype.Fields() {
+                       next, hasKey := keylist[field.Name]
+                       if !hasKey {
+                               // Optional fields that were not present get a 
null.
+                               if field.Nullable {
+                                       b.fields[i].AppendNull()
+                               }
                                continue
                        }
-                       idx, _ := 
b.dtype.(*arrow.StructType).FieldIdx(field.Name)
-                       if _, hasKey := keylist[field.Name]; !hasKey {
-                               b.fields[idx].AppendNull()
+
+                       valDec := json.NewDecoder(bytes.NewReader(next))
+                       valDec.UseNumber()
+                       if err := b.fields[i].UnmarshalOne(valDec); err != nil {
+                               b.Resize(-1)

Review Comment:
   Fixed, and thanks for the precise diagnosis. `StructBuilder` no longer calls 
`Resize(-1)`; it builds its own reusable `builderCheckpoint` (it needs one 
because it is the root builder for `array.FromJSON` on a struct type, which 
#1113 does not cover) and restores it on error. 
`TestStructBuilderRollsBackRowAfterNestedDecodeError` decodes 
`{"a":1,"b":[2,"bad"]}` followed by a valid row and asserts no panic, no 
retained row, and correct output.



##########
arrow/array/util.go:
##########
@@ -296,7 +296,11 @@ func RecordToJSON(rec arrow.RecordBatch, w io.Writer) 
error {
        cols := make(map[string]interface{})
        for i := 0; int64(i) < rec.NumRows(); i++ {
                for j, c := range rec.Columns() {
-                       cols[fields[j].Name] = c.GetOneForMarshal(i)
+                       if rec.Schema().Field(j).Nullable && c.IsNull(i) {

Review Comment:
   Defined it as an encoding error, as you suggested. `RecordToJSON` and 
`Struct.MarshalJSON` (so `RecordBatch.MarshalJSON` too, via 
`RecordToStructArray`) now return `arrow.ErrInvalid` when a non-nullable field 
holds a null, recursively. You were right that the old branch was pointless — 
`GetOneForMarshal` returns `nil` either way — so both no-op checks are 
reverted. The check honors parent-validity priority, so a null child under a 
null struct parent is not an error, matching what the encoders actually emit. 
Covered by `TestRecordToJSONRejectsNullInNonNullableField`, 
`TestStructMarshalJSONAllowsNullChildUnderNullParent` and a round-trip test.
   
   One scope note: `MarshalJSON` on non-struct array roots (a bare list array, 
say) still writes nulls for non-nullable element fields. Reading validates 
those roots; writing does not. Happy to extend it here if you want it in this 
PR.



##########
arrow/array/record.go:
##########
@@ -434,49 +436,74 @@ func (b *RecordBuilder) UnmarshalOne(dec *json.Decoder) 
error {
                return fmt.Errorf("record should start with '{', not %s", t)
        }
 
-       keylist := make(map[string]bool)
+       // consume one row checking for duplicates and nulls
+       keylist := make(map[string]json.RawMessage)
        for dec.More() {
                keyTok, err := dec.Token()
                if err != nil {
                        return err
                }
 
                key := keyTok.(string)
-               if keylist[key] {
+               if _, ok := keylist[key]; ok {
                        return fmt.Errorf("key %s shows up twice in row to be 
decoded", key)
                }
-               keylist[key] = true
+
+               var val json.RawMessage
+               if err := dec.Decode(&val); err != nil {
+                       return err
+               }
 
                indices := b.schema.FieldIndices(key)
                if len(indices) == 0 {
-                       var extra interface{}
-                       if err := dec.Decode(&extra); err != nil {
-                               return err
-                       }
                        continue
                }
 
-               if err := b.fields[indices[0]].UnmarshalOne(dec); err != nil {
-                       return err
+               idx := indices[0]
+
+               if bytes.Equal(val, []byte("null")) && 
!b.schema.Field(idx).Nullable {
+                       return fmt.Errorf("field '%s' is non-nullable but got 
null", key)
                }
+
+               keylist[key] = val
        }
 
        // consume the closing '}'
        if _, err := dec.Token(); err != nil {
                return err
        }
 
+       // check that all non-nullable fields were specified
        for i := 0; i < b.schema.NumFields(); i++ {

Review Comment:
   Enforced recursively now rather than narrowing the scope. 
`arrow/array/nullability.go` walks a buffered value against the `arrow.Field` 
tree, covering struct fields, the element field of list / large list / list 
view / fixed-size list, map key and item fields, union children, dictionary 
values, run-end-encoded values and extension storage. 
`ListOfNonNullable(int32)` rejects `[1,null]`, and missing non-nullable fields 
are rejected at any depth. Roots that are not records or structs are validated 
in `FromJSON`, so `array.FromJSON(mem, arrow.ListOfNonNullable(...), ...)` is 
checked too. `arrow/array/nullability_test.go` has the coverage.
   
   Unexpected bonus: buffering the row made `BenchmarkRecordFromJSON/Size_1000` 
~305x faster (7.77 s/op -> 25 ms/op), because decoding string tokens straight 
off the document decoder is quadratic in goccy's `(*Stream).Token`. Details in 
the PR description.



##########
arrow/array/struct.go:
##########
@@ -207,9 +207,14 @@ func (a *Struct) GetOneForMarshal(i int) interface{} {
        }
 
        tmp := make(map[string]interface{})
-       fieldList := a.data.dtype.(*arrow.StructType).Fields()
+       dtype := a.data.dtype.(*arrow.StructType)
+       fieldList := dtype.Fields()
        for j, d := range a.fields {
-               tmp[fieldList[j].Name] = d.GetOneForMarshal(i)
+               if dtype.Field(j).Nullable && a.IsNull(i) {
+                       tmp[fieldList[j].Name] = nil
+               } else {
+                       tmp[fieldList[j].Name] = d.GetOneForMarshal(i)
+               }
        }

Review Comment:
   Reverted this branch entirely — it was indeed unreachable, and even 
reachable it would have been a no-op since `GetOneForMarshal` returns `nil` for 
a null slot anyway. Non-nullable nulls are now rejected by `Struct.MarshalJSON` 
instead of being re-encoded.



##########
arrow/array/struct.go:
##########
@@ -467,19 +472,27 @@ func (b *StructBuilder) UnmarshalOne(dec *json.Decoder) 
error {
                        if keylist[key] {
                                return fmt.Errorf("key %s is specified twice", 
key)
                        }
-
                        keylist[key] = true
 
-                       idx, ok := b.dtype.(*arrow.StructType).FieldIdx(key)
+                       var next json.RawMessage
+                       if err := dec.Decode(&next); err != nil {
+                               return err
+                       }
+
+                       dtype := b.dtype.(*arrow.StructType)
+
+                       idx, ok := dtype.FieldIdx(key)
                        if !ok {
-                               var extra interface{}
-                               if err := dec.Decode(&extra); err != nil {
-                                       return err
-                               }
                                continue
                        }
 
-                       if err := b.fields[idx].UnmarshalOne(dec); err != nil {
+                       if bytes.Equal(next, []byte("null")) && 
!dtype.Field(idx).Nullable {
+                               return fmt.Errorf("field '%s' is non-nullable 
but got null", dtype.Field(idx).Name)
+                       }
+
+                       valDec := json.NewDecoder(bytes.NewReader(next))
+                       valDec.UseNumber()
+                       if err := b.fields[idx].UnmarshalOne(valDec); err != 
nil {
                                return err

Review Comment:
   Fixed. Validation now runs on the buffered value before anything is 
appended, and `StructBuilder` restores a pre-row `builderCheckpoint` if a child 
decode fails, so the builder is never left advanced after an error.



##########
arrow/array/record_test.go:
##########
@@ -511,14 +516,23 @@ func TestRecordBuilder(t *testing.T) {
                }
        }
 
+       err := b.UnmarshalJSON([]byte(`{"f1-i32": null, "f2-f64-notnull": null, 
"map": null}`))
+       assert.Contains(t, err.Error(), "field 'f2-f64-notnull' is non-nullable 
but got null")
+
+       err = b.UnmarshalJSON([]byte(`{"f1-i32": null, "map": null}`))
+       assert.Contains(t, err.Error(), "field 'f2-f64-notnull' is required but 
no value was given")
+
+       err = b.UnmarshalJSON([]byte(`{"f1-i32": 6, "f2-f64-notnull": 6.6, 
"map": [{"key": "4": "value": "d"}]}`))
+       assert.NoError(t, err)

Review Comment:
   Fixed — the missing comma is corrected.



##########
arrow/array/record_test.go:
##########
@@ -527,9 +541,27 @@ func TestRecordBuilder(t *testing.T) {
        if got, want := rec.ColumnName(0), schema.Field(0).Name; got != want {
                t.Fatalf("invalid column name: got=%q, want=%q", got, want)
        }
-       if got, want := rec.Column(2).String(), `[{["0" "2" "3"] ["a" "b" "c"]} 
{[] []} {[] []} {["3" "2" "3"] ["a" "b" "c"]} {[] []}]`; got != want {
-               t.Fatalf("invalid column name: got=%q, want=%q", got, want)
+
+       if got, want := rec.Column(0).String(), `[(null) 2 3 4 5 6]`; got != 
want {
+               t.Fatalf("invalid column values: got=%q, want=%q", got, want)
+       }
+       if got, want := rec.Column(1).String(), `[1.1 2.2 3.3 4.4 5.5 6.6]`; 
got != want {
+               t.Fatalf("invalid column values: got=%q, want=%q", got, want)
        }
+       if got, want := rec.Column(2).String(), `[{["0" "2" "3"] ["a" "b" "c"]} 
{[] []} {[] []} {["3" "2" "3"] ["a" "b" "c"]} {[] []} {["4"] ["d"]}]`; got != 
want {
+               t.Fatalf("invalid column values: got=%q, want=%q", got, want)
+       }
+
+       // roundtripping from JSON with array.FromJSON should work
+       arr := array.RecordToStructArray(rec)
+       defer arr.Release()
+       jsonStr, err := json.Marshal(arr)
+       assert.NoError(t, err)
+
+       roundtripped, _, err := array.FromJSON(mem, arr.DataType(), 
bytes.NewReader(jsonStr))
+       defer roundtripped.Release()
+       assert.NoError(t, err)
+       assert.Truef(t, array.Equal(arr, roundtripped), "JSON round trip 
returns different array: got=%q, want=%d", arr, roundtripped)

Review Comment:
   Fixed. `require.NoError` now runs before `defer roundtripped.Release()`, and 
the format verb is `%q` for both arguments.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to