This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 3a9323d2 fix(arrow): include REE value nullability in type equality 
(#1158)
3a9323d2 is described below

commit 3a9323d25aa9b04cf3dc1987dcab1bc1d8a1dd2c
Author: Minh Vu <[email protected]>
AuthorDate: Wed Aug 26 20:29:46 2026 +0200

    fix(arrow): include REE value nullability in type equality (#1158)
    
    ### Rationale for this change
    
    TypeEqual compares run-end and value data types for run-end encoded
    types, but it does not compare whether the value field is nullable.
    Structurally different types can therefore compare equal and produce the
    same type fingerprint.
    
    ### What changes are included in this PR?
    
    Include ValueNullable in run-end encoded type equality and fingerprints.
    
    ### Are these changes tested?
    
    Yes. The regression test verifies both comparison directions and
    distinct fingerprints for nullable and non-nullable value fields. The
    full arrow package suite passes.
    
    ### Are there any user-facing changes?
    
    Run-end encoded types with different value nullability now compare
    unequal and have different fingerprints.
---
 arrow/array/builder.go                         |  2 +-
 arrow/array/encoded.go                         | 16 +++++++-
 arrow/array/encoded_test.go                    | 53 ++++++++++++++++++++++++++
 arrow/cdata/cdata.go                           |  1 +
 arrow/compare.go                               |  3 +-
 arrow/compare_test.go                          | 16 ++++++++
 arrow/datatype_encoded.go                      |  6 ++-
 arrow/extensions/timestamp_with_offset.go      |  8 ++--
 arrow/extensions/timestamp_with_offset_test.go | 12 ++++++
 arrow/internal/arrdata/arrdata.go              | 16 +++++---
 arrow/internal/arrjson/arrjson.go              |  1 +
 arrow/ipc/metadata.go                          |  6 ++-
 12 files changed, 123 insertions(+), 17 deletions(-)

diff --git a/arrow/array/builder.go b/arrow/array/builder.go
index 18a799aa..11bffb01 100644
--- a/arrow/array/builder.go
+++ b/arrow/array/builder.go
@@ -451,7 +451,7 @@ func NewBuilder(mem memory.Allocator, dtype arrow.DataType) 
Builder {
                return NewDurationBuilder(mem, typ)
        case arrow.RUN_END_ENCODED:
                typ := dtype.(*arrow.RunEndEncodedType)
-               return NewRunEndEncodedBuilder(mem, typ.RunEnds(), 
typ.Encoded())
+               return newRunEndEncodedBuilder(mem, typ)
        case arrow.BINARY_VIEW:
                return NewBinaryViewBuilder(mem)
        case arrow.STRING_VIEW:
diff --git a/arrow/array/encoded.go b/arrow/array/encoded.go
index bd9a6fb5..e3cf21fa 100644
--- a/arrow/array/encoded.go
+++ b/arrow/array/encoded.go
@@ -42,7 +42,15 @@ type RunEndEncoded struct {
 }
 
 func NewRunEndEncodedArray(runEnds, values arrow.Array, logicalLength, offset 
int) *RunEndEncoded {
-       data := NewData(arrow.RunEndEncodedOf(runEnds.DataType(), 
values.DataType()), logicalLength,
+       return NewRunEndEncodedArrayWithType(
+               arrow.RunEndEncodedOf(runEnds.DataType(), values.DataType()),
+               runEnds, values, logicalLength, offset)
+}
+
+// NewRunEndEncodedArrayWithType constructs a run-end encoded array with the
+// provided type.
+func NewRunEndEncodedArrayWithType(dt *arrow.RunEndEncodedType, runEnds, 
values arrow.Array, logicalLength, offset int) *RunEndEncoded {
+       data := NewData(dt, logicalLength,
                []*memory.Buffer{nil}, []arrow.ArrayData{runEnds.Data(), 
values.Data()}, 0, offset)
        defer data.Release()
        return NewRunEndEncodedData(data)
@@ -399,7 +407,11 @@ type RunEndEncodedBuilder struct {
 }
 
 func NewRunEndEncodedBuilder(mem memory.Allocator, runEnds, encoded 
arrow.DataType) *RunEndEncodedBuilder {
-       dt := arrow.RunEndEncodedOf(runEnds, encoded)
+       return newRunEndEncodedBuilder(mem, arrow.RunEndEncodedOf(runEnds, 
encoded))
+}
+
+func newRunEndEncodedBuilder(mem memory.Allocator, dt 
*arrow.RunEndEncodedType) *RunEndEncodedBuilder {
+       runEnds, encoded := dt.RunEnds(), dt.Encoded()
        if !dt.ValidRunEndsType(runEnds) {
                panic("arrow/ree: invalid runEnds type for run length encoded 
array")
        }
diff --git a/arrow/array/encoded_test.go b/arrow/array/encoded_test.go
index 512adf85..3370d0f1 100644
--- a/arrow/array/encoded_test.go
+++ b/arrow/array/encoded_test.go
@@ -456,6 +456,59 @@ func TestRunEndEncodedBuilderDictionaryEmptyValue(t 
*testing.T) {
        assert.Equal(t, "", arr.GetOneForMarshal(0))
 }
 
+func TestRunEndEncodedBuilderPreservesValueNullability(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       dt := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int16, 
arrow.BinaryTypes.String)
+       dt.ValueNullable = false
+       bldr := array.NewBuilder(mem, dt)
+       defer bldr.Release()
+
+       assert.False(t, bldr.Type().(*arrow.RunEndEncodedType).ValueNullable)
+       arr := bldr.NewArray()
+       defer arr.Release()
+       assert.False(t, arr.DataType().(*arrow.RunEndEncodedType).ValueNullable)
+}
+
+func TestRunEndEncodedArrayWithTypePreservesValueNullability(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       dt := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int16, 
arrow.PrimitiveTypes.Int32)
+       dt.ValueNullable = false
+
+       newArray := func(runEndsJSON, valuesJSON string) *array.RunEndEncoded {
+               runEnds, _, err := array.FromJSON(mem, 
arrow.PrimitiveTypes.Int16, strings.NewReader(runEndsJSON))
+               require.NoError(t, err)
+               values, _, err := array.FromJSON(mem, 
arrow.PrimitiveTypes.Int32, strings.NewReader(valuesJSON))
+               require.NoError(t, err)
+               defer runEnds.Release()
+               defer values.Release()
+
+               return array.NewRunEndEncodedArrayWithType(dt, runEnds, values, 
2, 0)
+       }
+
+       first := newArray(`[1, 2]`, `[10, 20]`)
+       defer first.Release()
+       second := newArray(`[1, 2]`, `[30, 40]`)
+       defer second.Release()
+
+       schema := arrow.NewSchema([]arrow.Field{{Name: "values", Type: dt}}, 
nil)
+       record := array.NewRecordBatch(schema, []arrow.Array{first}, -1)
+       defer record.Release()
+
+       assert.False(t, 
first.DataType().(*arrow.RunEndEncodedType).ValueNullable)
+
+       concatenated, err := array.Concatenate([]arrow.Array{first, second}, 
mem)
+       require.NoError(t, err)
+       defer concatenated.Release()
+
+       assert.True(t, arrow.TypeEqual(dt, concatenated.DataType()))
+       assert.False(t, 
concatenated.DataType().(*arrow.RunEndEncodedType).ValueNullable)
+       assert.NoError(t, array.ValidateFull(concatenated))
+}
+
 func TestRunEndEncodedStringRoundTrip(t *testing.T) {
        // 1. create array
        mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
diff --git a/arrow/cdata/cdata.go b/arrow/cdata/cdata.go
index 9fbdd008..161440e4 100644
--- a/arrow/cdata/cdata.go
+++ b/arrow/cdata/cdata.go
@@ -347,6 +347,7 @@ func importSchema(schema *CArrowSchema) (ret arrow.Field, 
err error) {
                                return ret, fmt.Errorf("%w: run-end encoded 
arrays must have 2 children", arrow.ErrInvalid)
                        }
                        dt = arrow.RunEndEncodedOf(childFields[0].Type, 
childFields[1].Type)
+                       dt.(*arrow.RunEndEncodedType).ValueNullable = 
childFields[1].Nullable
                case 'm': // map type is basically a list of structs.
                        if f != "+m" {
                                return ret, fmt.Errorf("%w: invalid map type 
format %q", arrow.ErrInvalid, f)
diff --git a/arrow/compare.go b/arrow/compare.go
index abf5ac83..997d25ad 100644
--- a/arrow/compare.go
+++ b/arrow/compare.go
@@ -150,7 +150,8 @@ func TypeEqual(left, right DataType, opts 
...TypeEqualOption) bool {
        case *RunEndEncodedType:
                r := right.(*RunEndEncodedType)
                return TypeEqual(l.Encoded(), r.Encoded(), opts...) &&
-                       TypeEqual(l.runEnds, r.runEnds, opts...)
+                       TypeEqual(l.runEnds, r.runEnds, opts...) &&
+                       l.ValueNullable == r.ValueNullable
        case *ListViewType:
                return l.elem.Equal(right.(*ListViewType).elem)
        default:
diff --git a/arrow/compare_test.go b/arrow/compare_test.go
index 84f005d3..c78eb457 100644
--- a/arrow/compare_test.go
+++ b/arrow/compare_test.go
@@ -411,3 +411,19 @@ func TestTypeEqual(t *testing.T) {
                })
        }
 }
+
+func TestTypeEqualRunEndEncodedValueNullability(t *testing.T) {
+       nullable := RunEndEncodedOf(PrimitiveTypes.Int16, BinaryTypes.String)
+       nonNullable := RunEndEncodedOf(PrimitiveTypes.Int16, BinaryTypes.String)
+       nonNullable.ValueNullable = false
+
+       if TypeEqual(nullable, nonNullable) {
+               t.Fatal("run-end encoded types with different value nullability 
compared equal")
+       }
+       if TypeEqual(nonNullable, nullable) {
+               t.Fatal("run-end encoded type equality was not symmetric")
+       }
+       if nullable.Fingerprint() == nonNullable.Fingerprint() {
+               t.Fatal("run-end encoded types with different value nullability 
had identical fingerprints")
+       }
+}
diff --git a/arrow/datatype_encoded.go b/arrow/datatype_encoded.go
index 749f03a5..3eb7e83c 100644
--- a/arrow/datatype_encoded.go
+++ b/arrow/datatype_encoded.go
@@ -45,7 +45,11 @@ func (t *RunEndEncodedType) String() string {
 }
 
 func (t *RunEndEncodedType) Fingerprint() string {
-       return typeFingerprint(t) + "{" + t.runEnds.Fingerprint() + ";" + 
t.values.Fingerprint() + ";}"
+       nullability := "N"
+       if t.ValueNullable {
+               nullability = "n"
+       }
+       return typeFingerprint(t) + "{" + t.runEnds.Fingerprint() + ";" + 
t.values.Fingerprint() + ";" + nullability + ";}"
 }
 
 func (t *RunEndEncodedType) RunEnds() DataType { return t.runEnds }
diff --git a/arrow/extensions/timestamp_with_offset.go 
b/arrow/extensions/timestamp_with_offset.go
index 257707a0..77143a14 100644
--- a/arrow/extensions/timestamp_with_offset.go
+++ b/arrow/extensions/timestamp_with_offset.go
@@ -44,12 +44,9 @@ func isOffsetTypeOk(offsetType arrow.DataType) bool {
        case *arrow.DictionaryType:
                return arrow.TypeEqual(offsetType.ValueType, 
arrow.PrimitiveTypes.Int16)
        case *arrow.RunEndEncodedType:
-               return offsetType.ValidRunEndsType(offsetType.RunEnds()) &&
+               return !offsetType.ValueNullable &&
+                       offsetType.ValidRunEndsType(offsetType.RunEnds()) &&
                        arrow.TypeEqual(offsetType.Encoded(), 
arrow.PrimitiveTypes.Int16)
-               // FIXME: Technically this should be non-nullable, but a Arrow 
IPC does not deserialize
-               // ValueNullable properly, so enforcing this here would always 
fail when reading from an IPC
-               // stream
-               // !offsetType.ValueNullable
        default:
                return false
        }
@@ -153,6 +150,7 @@ func NewTimestampWithOffsetTypeDictionaryEncoded[I 
DictIndexType](unit arrow.Tim
 // valid run-ends type.
 func NewTimestampWithOffsetTypeRunEndEncoded[E RunEndsType](unit 
arrow.TimeUnit, runEnds E) *TimestampWithOffsetType {
        offsetType := arrow.RunEndEncodedOf(arrow.DataType(runEnds), 
arrow.PrimitiveTypes.Int16)
+       offsetType.ValueNullable = false
 
        v, _ := NewTimestampWithOffsetTypeCustomOffset(unit, offsetType)
        // SAFETY: This should never error as RunEndsType always a valid run 
ends type
diff --git a/arrow/extensions/timestamp_with_offset_test.go 
b/arrow/extensions/timestamp_with_offset_test.go
index 26221dc9..b45a706e 100644
--- a/arrow/extensions/timestamp_with_offset_test.go
+++ b/arrow/extensions/timestamp_with_offset_test.go
@@ -133,6 +133,7 @@ func TestTimestampWithOffsetTypeDeserializeInvalidStorage(t 
*testing.T) {
 
        badDict := &arrow.DictionaryType{IndexType: arrow.PrimitiveTypes.Int8, 
ValueType: arrow.PrimitiveTypes.Int32}
        badREE := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int32, 
arrow.PrimitiveTypes.Int32)
+       nullableREE := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int16, 
arrow.PrimitiveTypes.Int16)
 
        valid, err := base.Deserialize(base.StorageType(), "")
        require.NoError(t, err)
@@ -152,6 +153,7 @@ func TestTimestampWithOffsetTypeDeserializeInvalidStorage(t 
*testing.T) {
                "offset nullable":              arrow.StructOf(tsField, 
arrow.Field{Name: "offset_minutes", Type: arrow.PrimitiveTypes.Int16, Nullable: 
true}),
                "offset dict value not int16":  arrow.StructOf(tsField, 
arrow.Field{Name: "offset_minutes", Type: badDict}),
                "offset ree encoded not int16": arrow.StructOf(tsField, 
arrow.Field{Name: "offset_minutes", Type: badREE}),
+               "offset ree values nullable":   arrow.StructOf(tsField, 
arrow.Field{Name: "offset_minutes", Type: nullableREE}),
                "fields swapped":               arrow.StructOf(offField, 
tsField),
        }
 
@@ -163,6 +165,12 @@ func 
TestTimestampWithOffsetTypeDeserializeInvalidStorage(t *testing.T) {
        }
 }
 
+func TestTimestampWithOffsetTypeRejectsNullableRunEndEncodedOffset(t 
*testing.T) {
+       offsetType := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int16, 
arrow.PrimitiveTypes.Int16)
+       _, err := 
extensions.NewTimestampWithOffsetTypeCustomOffset(testTimeUnit, offsetType)
+       assert.Error(t, err)
+}
+
 func assertDictBasics[I extensions.DictIndexType](t *testing.T, indexType I) {
        typ := 
extensions.NewTimestampWithOffsetTypeDictionaryEncoded(testTimeUnit, indexType)
 
@@ -685,6 +693,10 @@ func TestTimestampWithOffsetTypeBatchIPCRoundTrip(t 
*testing.T) {
 
                        assert.Truef(t, batch.Schema().Equal(written.Schema()), 
"expected: %s\n\ngot: %s",
                                batch.Schema(), written.Schema())
+                       if _, ok := offsetType.(*arrow.RunEndEncodedType); ok {
+                               writtenType := 
written.Schema().Field(0).Type.(*extensions.TimestampWithOffsetType)
+                               assert.False(t, 
writtenType.OffsetType().(*arrow.RunEndEncodedType).ValueNullable)
+                       }
 
                        assert.Truef(t, array.RecordEqual(batch, written), 
"expected: %s\n\ngot: %s",
                                batch, written)
diff --git a/arrow/internal/arrdata/arrdata.go 
b/arrow/internal/arrdata/arrdata.go
index 095571a8..b4f0e626 100644
--- a/arrow/internal/arrdata/arrdata.go
+++ b/arrow/internal/arrdata/arrdata.go
@@ -1107,20 +1107,20 @@ func makeUnionRecords() []arrow.RecordBatch {
 
 func makeRunEndEncodedRecords() []arrow.RecordBatch {
        mem := memory.NewGoAllocator()
+       ree32Type := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int32, 
arrow.PrimitiveTypes.Int32)
+       ree32Type.ValueNullable = false
        schema := arrow.NewSchema([]arrow.Field{
                {Name: "ree16", Type: 
arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int16, arrow.BinaryTypes.String)},
-               {Name: "ree32", Type: 
arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int32, arrow.PrimitiveTypes.Int32)},
+               {Name: "ree32", Type: ree32Type},
                {Name: "ree64", Type: 
arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int64, arrow.BinaryTypes.Binary)},
        }, nil)
-
-       schema.Field(1).Type.(*arrow.RunEndEncodedType).ValueNullable = false
        isValid := []bool{true, false, true, false, true}
        chunks := [][]arrow.Array{
                {
                        runEndEncodedOf(
                                arrayOf(mem, []int16{5, 10, 20, 1020, 1120}, 
nil),
                                arrayOf(mem, []string{"foo", "bar", "baz", 
"foo", ""}, isValid), 1100, 20),
-                       runEndEncodedOf(
+                       runEndEncodedOfType(ree32Type,
                                arrayOf(mem, []int32{100, 200, 800, 1000, 
1100}, nil),
                                arrayOf(mem, []int32{-1, -2, -3, -4, -5}, nil), 
1100, 0),
                        runEndEncodedOf(
@@ -1131,7 +1131,7 @@ func makeRunEndEncodedRecords() []arrow.RecordBatch {
                        runEndEncodedOf(
                                arrayOf(mem, []int16{110, 160, 170, 1070, 
1120}, nil),
                                arrayOf(mem, []string{"super", "dee", "", 
"duper", "doo"}, isValid), 1100, 20),
-                       runEndEncodedOf(
+                       runEndEncodedOfType(ree32Type,
                                arrayOf(mem, []int32{100, 120, 710, 810, 1100}, 
nil),
                                arrayOf(mem, []int32{-1, -2, -3, -4, -5}, nil), 
1100, 0),
                        runEndEncodedOf(
@@ -1664,6 +1664,12 @@ func runEndEncodedOf(runEnds, values arrow.Array, 
logicalLen, offset int) arrow.
        return array.NewRunEndEncodedArray(runEnds, values, logicalLen, offset)
 }
 
+func runEndEncodedOfType(dt *arrow.RunEndEncodedType, runEnds, values 
arrow.Array, logicalLen, offset int) arrow.Array {
+       defer runEnds.Release()
+       defer values.Release()
+       return array.NewRunEndEncodedArrayWithType(dt, runEnds, values, 
logicalLen, offset)
+}
+
 func buildArray(bldr array.Builder, data arrow.Array) {
        defer data.Release()
 
diff --git a/arrow/internal/arrjson/arrjson.go 
b/arrow/internal/arrjson/arrjson.go
index 864356d7..afd34f50 100644
--- a/arrow/internal/arrjson/arrjson.go
+++ b/arrow/internal/arrjson/arrjson.go
@@ -541,6 +541,7 @@ func typeFromJSON(typ json.RawMessage, children 
[]FieldWrapper) (arrowType arrow
                        return
                }
                arrowType = arrow.RunEndEncodedOf(children[0].arrowType, 
children[1].arrowType)
+               arrowType.(*arrow.RunEndEncodedType).ValueNullable = 
children[1].Nullable
        }
 
        if arrowType == nil {
diff --git a/arrow/ipc/metadata.go b/arrow/ipc/metadata.go
index 54cde676..7a3a2321 100644
--- a/arrow/ipc/metadata.go
+++ b/arrow/ipc/metadata.go
@@ -482,7 +482,7 @@ func (fv *fieldVisitor) visit(field arrow.Field) {
                offsets[0] = fieldToFB(fv.b, fv.pos.Child(0),
                        arrow.Field{Name: "run_ends", Type: dt.RunEnds()}, 
fv.memo)
                offsets[1] = fieldToFB(fv.b, fv.pos.Child(1),
-                       arrow.Field{Name: "values", Type: dt.Encoded(), 
Nullable: true}, fv.memo)
+                       arrow.Field{Name: "values", Type: dt.Encoded(), 
Nullable: dt.ValueNullable}, fv.memo)
                flatbuf.RunEndEncodedStart(fv.b)
                fv.b.PrependUOffsetT(offsets[1])
                fv.b.PrependUOffsetT(offsets[0])
@@ -889,7 +889,9 @@ func concreteTypeFromFB(typ flatbuf.Type, data 
flatbuffers.Table, children []arr
                default:
                        return nil, fmt.Errorf("%w: arrow/ipc: run-end encoded 
run_ends field must be one of int16, int32, or int64 type", arrow.ErrInvalid)
                }
-               return arrow.RunEndEncodedOf(children[0].Type, 
children[1].Type), nil
+               ret := arrow.RunEndEncodedOf(children[0].Type, children[1].Type)
+               ret.ValueNullable = children[1].Nullable
+               return ret, nil
 
        default:
                panic(fmt.Errorf("arrow/ipc: type %v not implemented", 
flatbuf.EnumNamesType[typ]))

Reply via email to