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 c9791ae4 Add `TimestampWithOffset` canonical extension type (#558)
c9791ae4 is described below

commit c9791ae4041f3b06bda6bb893baa3382474048cd
Author: Lucas Valente <[email protected]>
AuthorDate: Sun Jul 19 03:36:35 2026 +0200

    Add `TimestampWithOffset` canonical extension type (#558)
    
    # Which issue does this PR close?
    
    Implements the new `arrow.timestamp_with_offset` canonical extension
    type for arrow-go.
    
    # Rationale for this change
    
    Be compatible with the Arrow columnar spec's
    `arrow.timestamp_with_offset` canonical extension type.
    
    # What changes are included in this PR?
    
    Adds a `TimestampWithOffset` extension type. This type represents a
    timestamp column that stores a potentially different timezone offset per
    value: the timestamp is stored in UTC alongside the original timezone
    offset in minutes. The offset-in-minutes field can be primitive-,
    dictionary-, or run-end-encoded.
    
    **This PR has been decoupled from the inner-nullability parity work it
    was previously based on.** Those comparison / JSON-marshaling changes
    now live in #918 and can be reviewed and merged independently. This PR
    is now scoped to just the extension type and touches only:
    
    - `arrow/extensions/timestamp_with_offset.go` (new)
    - `arrow/extensions/timestamp_with_offset_test.go` (new)
    - `arrow/extensions/extensions.go` (register the canonical type)
    
    To keep this PR self-contained, the JSON round-trip test asserts the
    values the type guarantees (per-row instant, timezone offset, and
    validity) rather than `array.RecordEqual`, which would compare inner
    non-nullable struct child validity bitmaps — the parity concern tracked
    separately in #918.
    
    # Are these changes tested?
    
    Yes — primitive/dictionary/run-end offset encodings, JSON marshal +
    round-trip, and IPC round-trip.
    
    # Are there any user-facing changes?
    
    Yes, this is a new canonical extension type.
    
    ---------
    
    Co-authored-by: Matt Topol <[email protected]>
    Co-authored-by: Matt Topol <[email protected]>
---
 arrow/extensions/extensions.go                 |   7 +-
 arrow/extensions/timestamp_with_offset.go      | 608 +++++++++++++++++++++++
 arrow/extensions/timestamp_with_offset_test.go | 646 +++++++++++++++++++++++++
 3 files changed, 1258 insertions(+), 3 deletions(-)

diff --git a/arrow/extensions/extensions.go b/arrow/extensions/extensions.go
index 04566c75..6f13aa64 100644
--- a/arrow/extensions/extensions.go
+++ b/arrow/extensions/extensions.go
@@ -21,11 +21,12 @@ import (
 )
 
 var canonicalExtensionTypes = []arrow.ExtensionType{
-       NewBool8Type(),
-       NewUUIDType(),
-       &OpaqueType{},
        &JSONType{},
+       &OpaqueType{},
+       &TimestampWithOffsetType{},
        &VariantType{},
+       NewBool8Type(),
+       NewUUIDType(),
 }
 
 func init() {
diff --git a/arrow/extensions/timestamp_with_offset.go 
b/arrow/extensions/timestamp_with_offset.go
new file mode 100644
index 00000000..47f8d241
--- /dev/null
+++ b/arrow/extensions/timestamp_with_offset.go
@@ -0,0 +1,608 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package extensions
+
+import (
+       "fmt"
+       "iter"
+       "math"
+       "reflect"
+       "slices"
+       "strings"
+       "time"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/internal/json"
+)
+
+// TimestampWithOffsetType represents a timestamp column that stores a 
timezone offset per row instead of
+// applying the same timezone offset to the entire column.
+type TimestampWithOffsetType struct {
+       arrow.ExtensionBase
+}
+
+func isOffsetTypeOk(offsetType arrow.DataType) bool {
+       switch offsetType := offsetType.(type) {
+       case *arrow.Int16Type:
+               return true
+       case *arrow.DictionaryType:
+               return arrow.TypeEqual(offsetType.ValueType, 
arrow.PrimitiveTypes.Int16)
+       case *arrow.RunEndEncodedType:
+               return 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
+       }
+}
+
+// Whether the storageType is compatible with TimestampWithOffset.
+//
+// Returns (time_unit, offset_type, ok). If ok is false, time_unit and 
offset_type are garbage.
+func isDataTypeCompatible(storageType arrow.DataType) (unit arrow.TimeUnit, 
offsetType arrow.DataType, ok bool) {
+       unit = arrow.Second
+       offsetType = arrow.PrimitiveTypes.Int16
+       ok = false
+
+       st, compat := storageType.(*arrow.StructType)
+       if !compat || st.NumFields() != 2 {
+               return
+       }
+
+       if ts, compat := st.Field(0).Type.(*arrow.TimestampType); compat && 
ts.TimeZone == "UTC" {
+               unit = ts.TimeUnit()
+       } else {
+               return
+       }
+
+       maybeOffset := st.Field(1)
+       offsetType = maybeOffset.Type
+
+       ok = st.Field(0).Name == "timestamp" &&
+               !st.Field(0).Nullable &&
+               maybeOffset.Name == "offset_minutes" &&
+               isOffsetTypeOk(offsetType) &&
+               !maybeOffset.Nullable
+       return
+}
+
+// NewTimestampWithOffsetType creates a new TimestampWithOffsetType with the 
underlying storage type set correctly to
+// Struct(timestamp=Timestamp(T, "UTC"), offset_minutes=Int16), where T is any 
TimeUnit.
+func NewTimestampWithOffsetType(unit arrow.TimeUnit) *TimestampWithOffsetType {
+       v, _ := NewTimestampWithOffsetTypeCustomOffset(unit, 
arrow.PrimitiveTypes.Int16)
+       // SAFETY: This should never error as Int16 is always a valid offset 
type
+
+       return v
+}
+
+// NewTimestampWithOffsetTypeCustomOffset creates a new 
TimestampWithOffsetType with the underlying storage type set correctly to
+// Struct(timestamp=Timestamp(T, "UTC"), offset_minutes=O), where T is any 
TimeUnit and O is a valid offset type.
+//
+// The error will be populated if the data type is not a valid encoding of the 
offsets field.
+func NewTimestampWithOffsetTypeCustomOffset(unit arrow.TimeUnit, offsetType 
arrow.DataType) (*TimestampWithOffsetType, error) {
+       if !isOffsetTypeOk(offsetType) {
+               return nil, fmt.Errorf("invalid offset type %s", offsetType)
+       }
+
+       return &TimestampWithOffsetType{
+               ExtensionBase: arrow.ExtensionBase{
+                       Storage: arrow.StructOf(
+                               arrow.Field{
+                                       Name: "timestamp",
+                                       Type: &arrow.TimestampType{
+                                               Unit:     unit,
+                                               TimeZone: "UTC",
+                                       },
+                                       Nullable: false,
+                               },
+                               arrow.Field{
+                                       Name:     "offset_minutes",
+                                       Type:     offsetType,
+                                       Nullable: false,
+                               },
+                       ),
+               },
+       }, nil
+}
+
+type DictIndexType interface {
+       *arrow.Int8Type | *arrow.Int16Type | *arrow.Int32Type | 
*arrow.Int64Type |
+               *arrow.Uint8Type | *arrow.Uint16Type | *arrow.Uint32Type | 
*arrow.Uint64Type
+}
+
+type RunEndsType interface {
+       *arrow.Int16Type | *arrow.Int32Type | *arrow.Int64Type
+}
+
+// NewTimestampWithOffsetTypeDictionaryEncoded creates a new 
TimestampWithOffsetType with the underlying storage type set correctly to
+// Struct(timestamp=Timestamp(T, "UTC"), offset_minutes=Dictionary(I, Int16)), 
where T is any TimeUnit and I is a
+// valid Dictionary index type.
+func NewTimestampWithOffsetTypeDictionaryEncoded[I DictIndexType](unit 
arrow.TimeUnit, index I) *TimestampWithOffsetType {
+       offsetType := arrow.DictionaryType{
+               IndexType: arrow.DataType(index),
+               ValueType: arrow.PrimitiveTypes.Int16,
+               Ordered:   false,
+       }
+       v, _ := NewTimestampWithOffsetTypeCustomOffset(unit, &offsetType)
+       // SAFETY: This should never error as DictIndexType is always a valid 
index type
+
+       return v
+}
+
+// NewTimestampWithOffsetTypeRunEndEncoded creates a new 
TimestampWithOffsetType with the underlying storage type set correctly to
+// Struct(timestamp=Timestamp(T, "UTC"), offset_minutes=RunEndEncoded(E, 
Int16)), where T is any TimeUnit and E is a
+// valid run-ends type.
+func NewTimestampWithOffsetTypeRunEndEncoded[E RunEndsType](unit 
arrow.TimeUnit, runEnds E) *TimestampWithOffsetType {
+       offsetType := arrow.RunEndEncodedOf(arrow.DataType(runEnds), 
arrow.PrimitiveTypes.Int16)
+
+       v, _ := NewTimestampWithOffsetTypeCustomOffset(unit, offsetType)
+       // SAFETY: This should never error as RunEndsType always a valid run 
ends type
+
+       return v
+
+}
+
+func (b *TimestampWithOffsetType) ArrayType() reflect.Type {
+       return reflect.TypeOf(TimestampWithOffsetArray{})
+}
+
+func (b *TimestampWithOffsetType) ExtensionName() string { return 
"arrow.timestamp_with_offset" }
+
+func (b *TimestampWithOffsetType) String() string {
+       return fmt.Sprintf("extension<%s>", b.ExtensionName())
+}
+
+func (e *TimestampWithOffsetType) MarshalJSON() ([]byte, error) {
+       return []byte(fmt.Sprintf(`{"name":"%s","metadata":%s}`, 
e.ExtensionName(), e.Serialize())), nil
+}
+
+func (b *TimestampWithOffsetType) Serialize() string { return "" }
+
+func (b *TimestampWithOffsetType) Deserialize(storageType arrow.DataType, data 
string) (arrow.ExtensionType, error) {
+       if data != "" && data != "{}" {
+               return nil, fmt.Errorf("serialized metadata for 
TimestampWithOffset extension type must be '' or '{}', found: %s", data)
+       }
+
+       timeUnit, offsetType, ok := isDataTypeCompatible(storageType)
+       if !ok {
+               return nil, fmt.Errorf("invalid storage type for 
TimestampWithOffsetType: %s", storageType.Name())
+       }
+
+       return NewTimestampWithOffsetTypeCustomOffset(timeUnit, offsetType)
+}
+
+func (b *TimestampWithOffsetType) ExtensionEquals(other arrow.ExtensionType) 
bool {
+       return b.ExtensionName() == other.ExtensionName() &&
+               arrow.TypeEqual(b.StorageType(), other.StorageType())
+}
+
+func (b *TimestampWithOffsetType) OffsetType() arrow.DataType {
+       return b.ExtensionBase.Storage.(*arrow.StructType).Field(1).Type
+}
+
+func (b *TimestampWithOffsetType) TimeUnit() arrow.TimeUnit {
+       return 
b.ExtensionBase.Storage.(*arrow.StructType).Field(0).Type.(*arrow.TimestampType).TimeUnit()
+}
+
+func (b *TimestampWithOffsetType) NewBuilder(mem memory.Allocator) 
array.Builder {
+       v, _ := NewTimestampWithOffsetBuilder(mem, b.TimeUnit(), b.OffsetType())
+       return v
+}
+
+// TimestampWithOffsetArray is a simple array of struct
+type TimestampWithOffsetArray struct {
+       array.ExtensionArrayBase
+}
+
+func (a *TimestampWithOffsetArray) String() string {
+       var o strings.Builder
+       o.WriteString("[")
+       for i := 0; i < a.Len(); i++ {
+               if i > 0 {
+                       o.WriteString(" ")
+               }
+               switch {
+               case a.IsNull(i):
+                       o.WriteString(array.NullValueStr)
+               default:
+                       fmt.Fprintf(&o, "\"%s\"", a.Value(i))
+               }
+       }
+       o.WriteString("]")
+       return o.String()
+}
+
+func timeFromFieldValues(utcTimestamp arrow.Timestamp, offsetMinutes int16, 
unit arrow.TimeUnit) time.Time {
+       // Derive the sign from the whole offset: integer hours is 0 for any 
offset
+       // with magnitude below one hour and so cannot carry a negative sign 
(e.g.
+       // -30 minutes must format as "UTC-00:30", not "UTC+00:30").
+       sign := "+"
+       abs := int(offsetMinutes)
+       if abs < 0 {
+               sign = "-"
+               abs = -abs
+       }
+
+       name := fmt.Sprintf("UTC%s%02d:%02d", sign, abs/60, abs%60)
+       loc := time.FixedZone(name, int(offsetMinutes)*60)
+       return utcTimestamp.ToTime(unit).In(loc)
+}
+
+func fieldValuesFromTime(t time.Time, unit arrow.TimeUnit) (arrow.Timestamp, 
int16) {
+       _, offsetSeconds := t.Zone()
+       offsetMinutes := int16(offsetSeconds / 60)
+       ts, _ := arrow.TimestampFromTime(t.UTC(), unit)
+       return ts, offsetMinutes
+}
+
+// Get the raw arrow values at the given index
+//
+// SAFETY: the value at i must not be nil
+func (a *TimestampWithOffsetArray) rawValueUnsafe(i int) (arrow.Timestamp, 
int16, arrow.TimeUnit) {
+       structs := a.Storage().(*array.Struct)
+
+       timestampField := structs.Field(0)
+       timestamps := timestampField.(*array.Timestamp)
+
+       timeUnit := timestampField.DataType().(*arrow.TimestampType).Unit
+       utcTimestamp := timestamps.Value(i)
+
+       var offsetMinutes int16
+
+       switch offsets := structs.Field(1).(type) {
+       case *array.Int16:
+               offsetMinutes = offsets.Value(i)
+       case *array.Dictionary:
+               offsetMinutes = 
offsets.Dictionary().(*array.Int16).Value(offsets.GetValueIndex(i))
+       case *array.RunEndEncoded:
+               offsetMinutes = 
offsets.Values().(*array.Int16).Value(offsets.GetPhysicalIndex(i))
+       }
+
+       return utcTimestamp, offsetMinutes, timeUnit
+}
+
+func (a *TimestampWithOffsetArray) Value(i int) time.Time {
+       if a.IsNull(i) {
+               return time.Time{}
+       }
+       utcTimestamp, offsetMinutes, timeUnit := a.rawValueUnsafe(i)
+       return timeFromFieldValues(utcTimestamp, offsetMinutes, timeUnit)
+}
+
+// Iterates over the array returning the timestamp at each position.
+//
+// The second parameter indicates whether the timestamp is valid or not.
+//
+// This will iterate using the fastest method given the underlying storage 
array
+func (a *TimestampWithOffsetArray) iterValues() iter.Seq2[time.Time, bool] {
+       return func(yield func(time.Time, bool) bool) {
+               structs := a.Storage().(*array.Struct)
+               offsets := structs.Field(1)
+               if reeOffsets, isRee := offsets.(*array.RunEndEncoded); isRee {
+                       timestampField := structs.Field(0)
+                       timeUnit := 
timestampField.DataType().(*arrow.TimestampType).Unit
+                       timestamps := timestampField.(*array.Timestamp)
+
+                       offsetValues := reeOffsets.Values().(*array.Int16)
+                       // Run-ends are absolute over the unsliced offsets 
child, so a sliced
+                       // array must begin at the physical run covering its 
logical offset and
+                       // advance using absolute positions (logicalOffset + i).
+                       logicalOffset := reeOffsets.Offset()
+                       offsetPhysicalIdx := reeOffsets.GetPhysicalOffset()
+
+                       var getRunEnd (func(int) int)
+                       switch arr := reeOffsets.RunEndsArr().(type) {
+                       case *array.Int16:
+                               getRunEnd = func(idx int) int { return 
int(arr.Value(idx)) }
+                       case *array.Int32:
+                               getRunEnd = func(idx int) int { return 
int(arr.Value(idx)) }
+                       case *array.Int64:
+                               getRunEnd = func(idx int) int { return 
int(arr.Value(idx)) }
+                       }
+
+                       for i := 0; i < a.Len(); i++ {
+                               if logicalOffset+i >= 
getRunEnd(offsetPhysicalIdx) {
+                                       offsetPhysicalIdx += 1
+                               }
+
+                               var ts time.Time
+                               valid := a.IsValid(i)
+                               if valid {
+                                       utcTimestamp := timestamps.Value(i)
+                                       offsetMinutes := 
offsetValues.Value(offsetPhysicalIdx)
+                                       v := timeFromFieldValues(utcTimestamp, 
offsetMinutes, timeUnit)
+                                       ts = v
+                               }
+
+                               if !yield(ts, valid) {
+                                       return
+                               }
+                       }
+               } else {
+                       for i := 0; i < a.Len(); i++ {
+                               var ts time.Time
+                               valid := a.IsValid(i)
+                               if valid {
+                                       utcTimestamp, offsetMinutes, timeUnit 
:= a.rawValueUnsafe(i)
+                                       v := timeFromFieldValues(utcTimestamp, 
offsetMinutes, timeUnit)
+                                       ts = v
+                               }
+
+                               if !yield(ts, valid) {
+                                       return
+                               }
+                       }
+               }
+       }
+}
+
+func (a *TimestampWithOffsetArray) Values() []time.Time {
+       return slices.Collect(func(yield func(time.Time) bool) {
+               for t := range a.iterValues() {
+                       if !yield(t) {
+                               return
+                       }
+               }
+       })
+}
+
+func (a *TimestampWithOffsetArray) ValueStr(i int) string {
+       switch {
+       case a.IsNull(i):
+               return array.NullValueStr
+       default:
+               return a.Value(i).String()
+       }
+}
+
+func (a *TimestampWithOffsetArray) MarshalJSON() ([]byte, error) {
+       values := make([]interface{}, a.Len())
+       i := 0
+       for ts, valid := range a.iterValues() {
+               if !valid {
+                       values[i] = nil
+               } else {
+                       values[i] = ts
+               }
+               i += 1
+       }
+       return json.Marshal(values)
+}
+
+func (a *TimestampWithOffsetArray) GetOneForMarshal(i int) interface{} {
+       if a.IsNull(i) {
+               return nil
+       }
+       return a.Value(i)
+}
+
+// noLastOffset is the sentinel value for TimestampWithOffsetBuilder.lastOffset
+// indicating that no run-end-encoded run has been started yet. It is 
deliberately
+// outside the range of valid timezone offsets in minutes (roughly [-720, 
840]) so
+// it can never compare equal to a real offset.
+const noLastOffset int16 = math.MaxInt16
+
+// TimestampWithOffsetBuilder is a convenience builder for the 
TimestampWithOffset extension type,
+// allowing arrays to be built with boolean values rather than the underlying 
storage type.
+type TimestampWithOffsetBuilder struct {
+       *array.ExtensionBuilder
+
+       // The layout used to parse any timestamps from strings. Defaults to 
time.RFC3339
+       Layout     string
+       unit       arrow.TimeUnit
+       offsetType arrow.DataType
+       // lastOffset tracks the offset of the current run when the offsets are 
run-end
+       // encoded, so we know when to start a new run. It is initialized to 
noLastOffset
+       // to signal that no run has been started yet.
+       lastOffset int16
+}
+
+// NewTimestampWithOffsetBuilder creates a new TimestampWithOffsetBuilder, 
exposing a convenient and efficient interface
+// for writing time.Time values to the underlying storage array.
+func NewTimestampWithOffsetBuilder(mem memory.Allocator, unit arrow.TimeUnit, 
offsetType arrow.DataType) (*TimestampWithOffsetBuilder, error) {
+       dataType, err := NewTimestampWithOffsetTypeCustomOffset(unit, 
offsetType)
+       if err != nil {
+               return nil, err
+       }
+
+       return &TimestampWithOffsetBuilder{
+               unit:             unit,
+               offsetType:       offsetType,
+               lastOffset:       noLastOffset,
+               Layout:           time.RFC3339,
+               ExtensionBuilder: array.NewExtensionBuilder(mem, dataType),
+       }, nil
+}
+
+// NewArray must route through this type's NewExtensionArray (not the embedded
+// ExtensionBuilder's) so that the run-end-encoding tracker is reset on reuse.
+func (b *TimestampWithOffsetBuilder) NewArray() arrow.Array {
+       return b.NewExtensionArray()
+}
+
+// NewExtensionArray finalizes the current array and resets lastOffset so a
+// reused builder starts a fresh run instead of continuing a run that belonged
+// to the array just finalized (the underlying REE builder is reset too).
+func (b *TimestampWithOffsetBuilder) NewExtensionArray() array.ExtensionArray {
+       arr := b.ExtensionBuilder.NewExtensionArray()
+       b.lastOffset = noLastOffset
+       return arr
+}
+
+// AppendNull resets the run-end-encoding tracker: the embedded struct builder
+// appends a null offset run, so a following value must start a new run instead
+// of continuing the null run.
+//
+// NOTE(#918): this writes a null into the non-nullable offset_minutes storage
+// field rather than a default value. This is intentional; comparison and JSON
+// round-trip parity for non-nullable inner struct fields is tracked separately
+// in https://github.com/apache/arrow-go/issues/918. Logical values (instant,
+// offset, validity) are preserved, but a full array.RecordEqual round-trip is
+// not guaranteed here.
+func (b *TimestampWithOffsetBuilder) AppendNull() {
+       b.ExtensionBuilder.AppendNull()
+       b.lastOffset = noLastOffset
+}
+
+func (b *TimestampWithOffsetBuilder) AppendNulls(n int) {
+       b.ExtensionBuilder.AppendNulls(n)
+       b.lastOffset = noLastOffset
+}
+
+func (b *TimestampWithOffsetBuilder) Append(v time.Time) {
+       timestamp, offsetMinutes := fieldValuesFromTime(v, b.unit)
+       offsetMinutes16 := int16(offsetMinutes)
+       structBuilder := b.Builder.(*array.StructBuilder)
+
+       structBuilder.Append(true)
+       
structBuilder.FieldBuilder(0).(*array.TimestampBuilder).Append(timestamp)
+
+       switch offsets := structBuilder.FieldBuilder(1).(type) {
+       case *array.Int16Builder:
+               offsets.Append(offsetMinutes16)
+       case *array.Int16DictionaryBuilder:
+               offsets.Append(offsetMinutes16)
+       case *array.RunEndEncodedBuilder:
+               if offsetMinutes != b.lastOffset {
+                       offsets.Append(1)
+                       
offsets.ValueBuilder().(*array.Int16Builder).Append(offsetMinutes16)
+               } else {
+                       offsets.ContinueRun(1)
+               }
+
+               b.lastOffset = offsetMinutes16
+       }
+
+}
+
+// By default, this will try to parse the string using the RFC3339 layout.
+//
+// You can change the default layout by using builder.SetLayout()
+func (b *TimestampWithOffsetBuilder) AppendValueFromString(s string) error {
+       if s == array.NullValueStr {
+               b.AppendNull()
+               return nil
+       }
+
+       parsed, err := time.Parse(b.Layout, s)
+       if err != nil {
+               return err
+       }
+
+       b.Append(parsed)
+       return nil
+}
+
+func (b *TimestampWithOffsetBuilder) AppendValues(values []time.Time, valids 
[]bool) {
+       if len(valids) != len(values) && len(valids) != 0 {
+               panic("len(values) != len(valids) && len(valids) != 0")
+       }
+       if len(valids) == 0 {
+               valids = make([]bool, len(values))
+               for i := range valids {
+                       valids[i] = true
+               }
+       }
+
+       structBuilder := b.Builder.(*array.StructBuilder)
+       timestamps := structBuilder.FieldBuilder(0).(*array.TimestampBuilder)
+
+       structBuilder.AppendValues(valids)
+       // SAFETY: by this point we know all buffers have available space given 
the earlier
+       // call to structBuilder.AppendValues which calls Reserve internally, 
so it's OK to
+       // call UnsafeAppend on inner builders
+
+       switch offsets := structBuilder.FieldBuilder(1).(type) {
+       case *array.Int16Builder:
+               for _, v := range values {
+                       timestamp, offsetMinutes := fieldValuesFromTime(v, 
b.unit)
+                       timestamps.UnsafeAppend(timestamp)
+                       offsets.UnsafeAppend(offsetMinutes)
+               }
+       case *array.Int16DictionaryBuilder:
+               for _, v := range values {
+                       timestamp, offsetMinutes := fieldValuesFromTime(v, 
b.unit)
+                       timestamps.UnsafeAppend(timestamp)
+                       offsets.UnsafeAppend(offsetMinutes)
+               }
+       case *array.RunEndEncodedBuilder:
+               offsetValuesBuilder := 
offsets.ValueBuilder().(*array.Int16Builder)
+               for i, v := range values {
+                       timestamp, offsetMinutes := fieldValuesFromTime(v, 
b.unit)
+                       timestamps.UnsafeAppend(timestamp)
+
+                       // A null row's offset is masked by the struct validity 
bitmap, so we
+                       // continue the current run to maximize compression. A 
run must still be
+                       // started when none exists yet (e.g. leading null 
rows), otherwise
+                       // ContinueRun would advance the run-ends without a 
matching entry in the
+                       // values child and produce an invalid run-end encoded 
array. lastOffset
+                       // is only updated when a new run starts, so a null row 
never splits a
+                       // contiguous run into two adjacent runs sharing the 
same value.
+                       valid := valids[i]
+                       if b.lastOffset == noLastOffset || (valid && 
offsetMinutes != b.lastOffset) {
+                               offsets.Append(1)
+                               offsetValuesBuilder.Append(offsetMinutes)
+                               b.lastOffset = offsetMinutes
+                       } else {
+                               offsets.ContinueRun(1)
+                       }
+               }
+       }
+}
+
+func (b *TimestampWithOffsetBuilder) UnmarshalOne(dec *json.Decoder) error {
+       tok, err := dec.Token()
+       if err != nil {
+               return fmt.Errorf("failed to decode json: %w", err)
+       }
+
+       switch raw := tok.(type) {
+       case string:
+               t, err := time.Parse(b.Layout, raw)
+               if err != nil {
+                       return fmt.Errorf("failed to parse string \"%s\" as 
time.Time using layout \"%s\"", raw, b.Layout)
+               }
+               b.Append(t)
+       case nil:
+               b.AppendNull()
+       default:
+               return fmt.Errorf("expected date string")
+       }
+
+       return nil
+}
+
+func (b *TimestampWithOffsetBuilder) Unmarshal(dec *json.Decoder) error {
+       for dec.More() {
+               if err := b.UnmarshalOne(dec); err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+var (
+       _ arrow.ExtensionType          = (*TimestampWithOffsetType)(nil)
+       _ array.CustomExtensionBuilder = (*TimestampWithOffsetType)(nil)
+       _ array.ExtensionArray         = (*TimestampWithOffsetArray)(nil)
+       _ array.Builder                = (*TimestampWithOffsetBuilder)(nil)
+)
diff --git a/arrow/extensions/timestamp_with_offset_test.go 
b/arrow/extensions/timestamp_with_offset_test.go
new file mode 100644
index 00000000..efbdfd28
--- /dev/null
+++ b/arrow/extensions/timestamp_with_offset_test.go
@@ -0,0 +1,646 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package extensions_test
+
+import (
+       "bytes"
+       "fmt"
+       "testing"
+       "time"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/extensions"
+       "github.com/apache/arrow-go/v18/arrow/ipc"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/internal/json"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+var testTimeUnit = arrow.Microsecond
+
+var epoch = time.Unix(0, 0).In(time.FixedZone("UTC+00:00", 0))
+
+var testDate0 = time.Date(2025, 01, 01, 00, 00, 00, 00, 
time.FixedZone("UTC+00:00", 0))
+
+var testZone1 = time.FixedZone("UTC-08:30", -8*60*60-30*60)
+var testDate1 = testDate0.In(testZone1)
+
+var testZone2 = time.FixedZone("UTC+11:00", +11*60*60)
+var testDate2 = testDate0.In(testZone2)
+
+func dict(index arrow.DataType) arrow.DataType {
+       return &arrow.DictionaryType{
+               IndexType: index,
+               ValueType: arrow.PrimitiveTypes.Int16,
+               Ordered:   false,
+       }
+}
+
+func ree(runEnds arrow.DataType) arrow.DataType {
+       v := arrow.RunEndEncodedOf(runEnds, arrow.PrimitiveTypes.Int16)
+       v.ValueNullable = false
+       return v
+}
+
+// All tests use this in a for loop to make sure everything works for every 
possible
+// encoding of offsets (primitive, dictionary, run-end)
+var allAllowedOffsetTypes = make(map[string]arrow.DataType)
+
+func init() {
+       // primitive offsetType
+       allAllowedOffsetTypes["primitive-int16"] = arrow.PrimitiveTypes.Int16
+
+       // dict-encoded offsetType
+       allAllowedOffsetTypes["dict-Uint8"] = dict(arrow.PrimitiveTypes.Uint8)
+       allAllowedOffsetTypes["dict-Uint16"] = dict(arrow.PrimitiveTypes.Uint16)
+       allAllowedOffsetTypes["dict-Uint32"] = dict(arrow.PrimitiveTypes.Uint32)
+       allAllowedOffsetTypes["dict-Uint64"] = dict(arrow.PrimitiveTypes.Uint64)
+       allAllowedOffsetTypes["dict-Int8"] = dict(arrow.PrimitiveTypes.Int8)
+       allAllowedOffsetTypes["dict-Int16"] = dict(arrow.PrimitiveTypes.Int16)
+       allAllowedOffsetTypes["dict-Int32"] = dict(arrow.PrimitiveTypes.Int32)
+       allAllowedOffsetTypes["dict-Int64"] = dict(arrow.PrimitiveTypes.Int64)
+
+       // run-end encoded offsetType
+       allAllowedOffsetTypes["ree-Int16"] = ree(arrow.PrimitiveTypes.Int16)
+       allAllowedOffsetTypes["ree-Int32"] = ree(arrow.PrimitiveTypes.Int32)
+       allAllowedOffsetTypes["ree-Int64"] = ree(arrow.PrimitiveTypes.Int64)
+}
+
+func TestTimestampWithOffsetTypePrimitiveBasics(t *testing.T) {
+       typ := extensions.NewTimestampWithOffsetType(testTimeUnit)
+
+       assert.Equal(t, "arrow.timestamp_with_offset", typ.ExtensionName())
+       assert.True(t, typ.ExtensionEquals(typ))
+
+       assert.True(t, arrow.TypeEqual(typ, typ))
+       assert.True(t, arrow.TypeEqual(
+               arrow.StructOf(
+                       arrow.Field{
+                               Name: "timestamp",
+                               Type: &arrow.TimestampType{
+                                       Unit:     testTimeUnit,
+                                       TimeZone: "UTC",
+                               },
+                               Nullable: false,
+                       },
+                       arrow.Field{
+                               Name:     "offset_minutes",
+                               Type:     arrow.PrimitiveTypes.Int16,
+                               Nullable: false,
+                       },
+               ),
+               typ.StorageType()))
+
+       assert.Equal(t, "extension<arrow.timestamp_with_offset>", typ.String())
+}
+
+func TestTimestampWithOffsetTypeDeserializeMetadata(t *testing.T) {
+       typ := extensions.NewTimestampWithOffsetType(testTimeUnit)
+       storage := typ.StorageType()
+
+       for _, data := range []string{"", "{}"} {
+               got, err := typ.Deserialize(storage, data)
+               assert.NoError(t, err)
+               assert.True(t, typ.ExtensionEquals(got))
+       }
+
+       _, err := typ.Deserialize(storage, "not-empty")
+       assert.Error(t, err)
+}
+
+func TestTimestampWithOffsetTypeDeserializeInvalidStorage(t *testing.T) {
+       base := extensions.NewTimestampWithOffsetType(testTimeUnit)
+
+       utcTS := &arrow.TimestampType{Unit: testTimeUnit, TimeZone: "UTC"}
+       tsField := arrow.Field{Name: "timestamp", Type: utcTS}
+       offField := arrow.Field{Name: "offset_minutes", Type: 
arrow.PrimitiveTypes.Int16}
+
+       badDict := &arrow.DictionaryType{IndexType: arrow.PrimitiveTypes.Int8, 
ValueType: arrow.PrimitiveTypes.Int32}
+       badREE := arrow.RunEndEncodedOf(arrow.PrimitiveTypes.Int32, 
arrow.PrimitiveTypes.Int32)
+
+       valid, err := base.Deserialize(base.StorageType(), "")
+       require.NoError(t, err)
+       require.NotNil(t, valid)
+
+       invalid := map[string]arrow.DataType{
+               "not a struct":                 arrow.PrimitiveTypes.Int16,
+               "too few fields":               arrow.StructOf(tsField),
+               "too many fields":              arrow.StructOf(tsField, 
offField, offField),
+               "timestamp wrong name":         
arrow.StructOf(arrow.Field{Name: "ts", Type: utcTS}, offField),
+               "timestamp not a timestamp":    
arrow.StructOf(arrow.Field{Name: "timestamp", Type: 
arrow.PrimitiveTypes.Int64}, offField),
+               "timestamp non-UTC zone":       
arrow.StructOf(arrow.Field{Name: "timestamp", Type: &arrow.TimestampType{Unit: 
testTimeUnit, TimeZone: "America/New_York"}}, offField),
+               "timestamp empty zone":         
arrow.StructOf(arrow.Field{Name: "timestamp", Type: &arrow.TimestampType{Unit: 
testTimeUnit}}, offField),
+               "timestamp nullable":           
arrow.StructOf(arrow.Field{Name: "timestamp", Type: utcTS, Nullable: true}, 
offField),
+               "offset wrong name":            arrow.StructOf(tsField, 
arrow.Field{Name: "offset", Type: arrow.PrimitiveTypes.Int16}),
+               "offset wrong primitive type":  arrow.StructOf(tsField, 
arrow.Field{Name: "offset_minutes", Type: arrow.PrimitiveTypes.Int32}),
+               "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}),
+               "fields swapped":               arrow.StructOf(offField, 
tsField),
+       }
+
+       for name, storage := range invalid {
+               t.Run(name, func(t *testing.T) {
+                       _, err := base.Deserialize(storage, "")
+                       assert.Error(t, err)
+               })
+       }
+}
+
+func assertDictBasics[I extensions.DictIndexType](t *testing.T, indexType I) {
+       typ := 
extensions.NewTimestampWithOffsetTypeDictionaryEncoded(testTimeUnit, indexType)
+
+       assert.Equal(t, "arrow.timestamp_with_offset", typ.ExtensionName())
+       assert.True(t, typ.ExtensionEquals(typ))
+
+       assert.True(t, arrow.TypeEqual(typ, typ))
+       assert.True(t, arrow.TypeEqual(
+               arrow.StructOf(
+                       arrow.Field{
+                               Name: "timestamp",
+                               Type: &arrow.TimestampType{
+                                       Unit:     testTimeUnit,
+                                       TimeZone: "UTC",
+                               },
+                               Nullable: false,
+                       },
+                       arrow.Field{
+                               Name:     "offset_minutes",
+                               Type:     dict(arrow.DataType(indexType)),
+                               Nullable: false,
+                       },
+               ),
+               typ.StorageType()))
+
+       assert.Equal(t, "extension<arrow.timestamp_with_offset>", typ.String())
+}
+
+func TestTimestampWithOffsetTypeDictionaryEncodedBasics(t *testing.T) {
+       assertDictBasics(t, &arrow.Uint8Type{})
+       assertDictBasics(t, &arrow.Uint16Type{})
+       assertDictBasics(t, &arrow.Uint32Type{})
+       assertDictBasics(t, &arrow.Uint64Type{})
+       assertDictBasics(t, &arrow.Int8Type{})
+       assertDictBasics(t, &arrow.Int16Type{})
+       assertDictBasics(t, &arrow.Int32Type{})
+       assertDictBasics(t, &arrow.Int64Type{})
+}
+
+func assertReeBasics[E extensions.RunEndsType](t *testing.T, runEndsType E) {
+       typ := extensions.NewTimestampWithOffsetTypeRunEndEncoded(testTimeUnit, 
runEndsType)
+
+       assert.Equal(t, "arrow.timestamp_with_offset", typ.ExtensionName())
+       assert.True(t, typ.ExtensionEquals(typ))
+
+       assert.True(t, arrow.TypeEqual(typ, typ))
+       assert.True(t, arrow.TypeEqual(
+               arrow.StructOf(
+                       arrow.Field{
+                               Name: "timestamp",
+                               Type: &arrow.TimestampType{
+                                       Unit:     testTimeUnit,
+                                       TimeZone: "UTC",
+                               },
+                               Nullable: false,
+                       },
+                       arrow.Field{
+                               Name:     "offset_minutes",
+                               Type:     ree(arrow.DataType(runEndsType)),
+                               Nullable: false,
+                       },
+               ),
+               typ.StorageType()))
+
+       assert.Equal(t, "extension<arrow.timestamp_with_offset>", typ.String())
+}
+
+func TestTimestampWithOffsetTypeRunEndEncodedBasics(t *testing.T) {
+       assertReeBasics(t, &arrow.Int16Type{})
+       assertReeBasics(t, &arrow.Int32Type{})
+       assertReeBasics(t, &arrow.Int64Type{})
+}
+
+func TestTimestampWithOffsetEquals(t *testing.T) {
+       // Completely different types are not equal
+       assert.False(t, 
extensions.NewTimestampWithOffsetType(arrow.Nanosecond).ExtensionEquals(extensions.NewBool8Type()))
+
+       // Different time units are not equal
+       assert.False(t, 
extensions.NewTimestampWithOffsetType(arrow.Nanosecond).ExtensionEquals(extensions.NewTimestampWithOffsetType(arrow.Microsecond)))
+       assert.False(t, 
extensions.NewTimestampWithOffsetType(arrow.Nanosecond).ExtensionEquals(extensions.NewTimestampWithOffsetType(arrow.Second)))
+       assert.False(t, 
extensions.NewTimestampWithOffsetType(arrow.Microsecond).ExtensionEquals(extensions.NewTimestampWithOffsetType(arrow.Second)))
+
+       // Different underlying storage type is not equal
+       assert.False(t, 
extensions.NewTimestampWithOffsetType(arrow.Microsecond).ExtensionEquals(extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond,
 &arrow.Int16Type{})))
+       assert.False(t, 
extensions.NewTimestampWithOffsetType(arrow.Microsecond).ExtensionEquals(extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond,
 &arrow.Int16Type{})))
+       assert.False(t, 
extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond, 
&arrow.Int16Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond,
 &arrow.Int16Type{})))
+
+       // Dict-encoding key type is not equal
+       assert.False(t, 
extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond, 
&arrow.Int16Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond,
 &arrow.Uint16Type{})))
+
+       // REE index type is not equal
+       assert.False(t, 
extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond, 
&arrow.Int16Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond,
 &arrow.Int32Type{})))
+
+       // Equals OK
+       assert.True(t, 
extensions.NewTimestampWithOffsetType(arrow.Nanosecond).ExtensionEquals(extensions.NewTimestampWithOffsetType(arrow.Nanosecond)))
+       assert.True(t, 
extensions.NewTimestampWithOffsetType(arrow.Microsecond).ExtensionEquals(extensions.NewTimestampWithOffsetType(arrow.Microsecond)))
+       assert.True(t, 
extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond, 
&arrow.Int16Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond,
 &arrow.Int16Type{})))
+       assert.True(t, 
extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond, 
&arrow.Uint16Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeDictionaryEncoded(arrow.Microsecond,
 &arrow.Uint16Type{})))
+       assert.True(t, 
extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond, 
&arrow.Int16Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond,
 &arrow.Int16Type{})))
+       assert.True(t, 
extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond, 
&arrow.Int32Type{}).ExtensionEquals(extensions.NewTimestampWithOffsetTypeRunEndEncoded(arrow.Microsecond,
 &arrow.Int32Type{})))
+}
+
+func TestTimestampWithOffsetExtensionBuilder(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       for name, offsetType := range allAllowedOffsetTypes {
+               t.Run(name, func(t *testing.T) {
+                       builder, _ := 
extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, offsetType)
+
+                       builder.Append(testDate0)
+                       builder.AppendNull()
+                       builder.Append(testDate1)
+                       builder.Append(testDate2)
+                       builder.Append(epoch)
+
+                       // it should build the array with the correct size
+                       arr := builder.NewArray()
+                       typedArr := arr.(*extensions.TimestampWithOffsetArray)
+                       assert.Equal(t, 5, arr.Data().Len())
+                       defer arr.Release()
+
+                       // typedArr.Value(i) should return values adjusted for 
their original timezone
+                       assert.Equal(t, testDate0, typedArr.Value(0))
+                       assert.Equal(t, testDate1, typedArr.Value(2))
+                       assert.Equal(t, testDate2, typedArr.Value(3))
+                       assert.Equal(t, epoch, typedArr.Value(4))
+
+                       // storage TimeUnit should be the same as we pass in to 
the builder, and storage timezone should be UTC
+                       timestampStructField := 
typedArr.Storage().(*array.Struct).Field(0)
+                       timestampStructDataType := 
timestampStructField.DataType().(*arrow.TimestampType)
+                       assert.Equal(t, timestampStructDataType.Unit, 
testTimeUnit)
+                       assert.Equal(t, timestampStructDataType.TimeZone, "UTC")
+
+                       // stored values should be equivalent to the raw values 
in UTC
+                       timestampsArr := timestampStructField.(*array.Timestamp)
+                       assert.Equal(t, testDate0.In(time.UTC), 
timestampsArr.Value(0).ToTime(testTimeUnit))
+                       assert.Equal(t, testDate1.In(time.UTC), 
timestampsArr.Value(2).ToTime(testTimeUnit))
+                       assert.Equal(t, testDate2.In(time.UTC), 
timestampsArr.Value(3).ToTime(testTimeUnit))
+                       assert.Equal(t, epoch.In(time.UTC), 
timestampsArr.Value(4).ToTime(testTimeUnit))
+
+                       // the array should encode itself as JSON and string
+                       arrStr := arr.String()
+                       assert.Equal(t, fmt.Sprintf(`["%[1]s" (null) "%[2]s" 
"%[3]s" "%[4]s"]`, testDate0, testDate1, testDate2, epoch), arrStr)
+                       jsonStr, err := json.Marshal(arr)
+                       assert.NoError(t, err)
+
+                       // roundtripping from JSON with array.FromJSON should 
work
+                       expectedDataType, _ := 
extensions.NewTimestampWithOffsetTypeCustomOffset(testTimeUnit, offsetType)
+                       roundtripped, _, err := array.FromJSON(mem, 
expectedDataType, bytes.NewReader(jsonStr))
+                       defer roundtripped.Release()
+                       assert.NoError(t, err)
+                       assert.Truef(t, array.Equal(arr, roundtripped), 
"expected %s\n\ngot %s", arr, roundtripped)
+               })
+       }
+}
+
+func TestTimestampWithOffsetBuilderAppendValuesLeadingNull(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       for name, offsetType := range allAllowedOffsetTypes {
+               t.Run(name, func(t *testing.T) {
+                       builder, err := 
extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, offsetType)
+                       require.NoError(t, err)
+
+                       // A leading null as the very first operation on a 
fresh builder must not
+                       // corrupt the offsets. Reading every value below would 
panic on a
+                       // run-end encoded array whose run-ends and values 
children fell out of
+                       // sync (which happened when a run was continued before 
one existed).
+                       builder.AppendValues([]time.Time{epoch, testDate1, 
testDate2}, []bool{false, true, true})
+
+                       arr := builder.NewArray()
+                       defer arr.Release()
+                       typedArr := arr.(*extensions.TimestampWithOffsetArray)
+
+                       require.Equal(t, 3, arr.Data().Len())
+                       assert.True(t, typedArr.IsNull(0))
+                       assert.Equal(t, testDate1, typedArr.Value(1))
+                       assert.Equal(t, testDate2, typedArr.Value(2))
+               })
+       }
+}
+
+func TestTimestampWithOffsetBuilderRunEndEncodedNullContinuesRun(t *testing.T) 
{
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       builder, err := extensions.NewTimestampWithOffsetBuilder(mem, 
testTimeUnit, ree(arrow.PrimitiveTypes.Int16))
+       require.NoError(t, err)
+
+       // The same offset surrounds a null. The null must continue the current 
run
+       // rather than reset the tracked offset, otherwise one logical run is 
split
+       // into two adjacent runs that share the same value.
+       builder.AppendValues([]time.Time{testDate1, epoch, testDate1}, 
[]bool{true, false, true})
+
+       arr := builder.NewArray()
+       defer arr.Release()
+       typedArr := arr.(*extensions.TimestampWithOffsetArray)
+
+       offsets := 
typedArr.Storage().(*array.Struct).Field(1).(*array.RunEndEncoded)
+       assert.Equal(t, 1, offsets.Values().Len())
+
+       assert.Equal(t, testDate1, typedArr.Value(0))
+       assert.True(t, typedArr.IsNull(1))
+       assert.Equal(t, testDate1, typedArr.Value(2))
+}
+
+func TestTimestampWithOffsetBuilderAppendValuesNilValids(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       // nil and empty validity slices must both be treated as all-valid so 
the
+       // parent struct and its child builders stay the same length.
+       validsVariants := map[string][]bool{"nil": nil, "empty": {}}
+
+       for name, offsetType := range allAllowedOffsetTypes {
+               for validsName, valids := range validsVariants {
+                       t.Run(name+"/"+validsName, func(t *testing.T) {
+                               builder, err := 
extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, offsetType)
+                               require.NoError(t, err)
+
+                               builder.AppendValues([]time.Time{testDate0, 
testDate1, testDate2}, valids)
+
+                               arr := builder.NewArray()
+                               defer arr.Release()
+                               typedArr := 
arr.(*extensions.TimestampWithOffsetArray)
+
+                               require.Equal(t, 3, arr.Data().Len())
+                               assert.Equal(t, testDate0, typedArr.Value(0))
+                               assert.Equal(t, testDate1, typedArr.Value(1))
+                               assert.Equal(t, testDate2, typedArr.Value(2))
+                       })
+               }
+       }
+}
+
+func TestTimestampWithOffsetBuilderAppendValuesMismatchedValidsPanics(t 
*testing.T) {
+       builder, err := 
extensions.NewTimestampWithOffsetBuilder(memory.DefaultAllocator, testTimeUnit, 
arrow.PrimitiveTypes.Int16)
+       require.NoError(t, err)
+       defer builder.Release()
+
+       assert.Panics(t, func() {
+               builder.AppendValues([]time.Time{testDate0, testDate1}, 
[]bool{true})
+       })
+}
+
+func TestTimestampWithOffsetBuilderReuseAfterNewArray(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       for name, offsetType := range allAllowedOffsetTypes {
+               t.Run(name, func(t *testing.T) {
+                       builder, err := 
extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, offsetType)
+                       require.NoError(t, err)
+
+                       builder.Append(testDate1)
+                       arr1 := builder.NewArray()
+                       defer arr1.Release()
+
+                       // Reusing the builder must start a fresh run even when 
the first value
+                       // repeats the previous array's offset; otherwise a 
run-end-encoded
+                       // offset is continued on a freshly reset (empty) 
builder.
+                       builder.Append(testDate1)
+                       builder.Append(testDate2)
+                       arr2 := builder.NewArray()
+                       defer arr2.Release()
+
+                       typedArr := arr2.(*extensions.TimestampWithOffsetArray)
+                       require.Equal(t, 2, arr2.Data().Len())
+                       assert.Equal(t, testDate1, typedArr.Value(0))
+                       assert.Equal(t, testDate2, typedArr.Value(1))
+               })
+       }
+}
+
+func TestTimestampWithOffsetBuilderAppendNullBetweenEqualOffsets(t *testing.T) 
{
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       builder, err := extensions.NewTimestampWithOffsetBuilder(mem, 
testTimeUnit, ree(arrow.PrimitiveTypes.Int16))
+       require.NoError(t, err)
+
+       // AppendNull appends a null offset run; the following value repeats the
+       // pre-null offset and must start its own run instead of being folded 
into
+       // the null run (which would decode the trailing value's offset as 
null).
+       builder.Append(testDate1)
+       builder.AppendNull()
+       builder.Append(testDate1)
+
+       arr := builder.NewArray()
+       defer arr.Release()
+       typedArr := arr.(*extensions.TimestampWithOffsetArray)
+
+       require.Equal(t, 3, arr.Data().Len())
+       assert.Equal(t, testDate1, typedArr.Value(0))
+       assert.True(t, typedArr.IsNull(1))
+       assert.Equal(t, testDate1, typedArr.Value(2))
+}
+
+func TestTimestampWithOffsetRunEndEncodedSliced(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       for _, runEnds := range []arrow.DataType{
+               arrow.PrimitiveTypes.Int16, arrow.PrimitiveTypes.Int32, 
arrow.PrimitiveTypes.Int64,
+       } {
+               t.Run(runEnds.String(), func(t *testing.T) {
+                       builder, err := 
extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, ree(runEnds))
+                       require.NoError(t, err)
+
+                       // Offsets [0,0,-510,-510,-510,660,0] give run-ends 
[2,5,6,7], so a
+                       // slice starting at index 3 begins inside a later run: 
the case where
+                       // an offset-unaware physical index decodes the wrong 
run.
+                       values := []time.Time{testDate0, testDate0, testDate1, 
testDate1, testDate1, testDate2, epoch}
+                       builder.AppendValues(values, nil)
+                       arr := 
builder.NewArray().(*extensions.TimestampWithOffsetArray)
+                       defer arr.Release()
+
+                       want := arr.Values()
+
+                       for start := 0; start < arr.Len(); start++ {
+                               for end := start; end <= arr.Len(); end++ {
+                                       sliced := array.NewSlice(arr, 
int64(start), int64(end)).(*extensions.TimestampWithOffsetArray)
+                                       got := sliced.Values()
+                                       require.Equalf(t, end-start, len(got), 
"slice [%d:%d]", start, end)
+                                       for k := 0; k < end-start; k++ {
+                                               require.Truef(t, 
want[start+k].Equal(got[k]), "slice [%d:%d] row %d instant: want %s got %s", 
start, end, k, want[start+k], got[k])
+                                               _, wantOff := 
want[start+k].Zone()
+                                               _, gotOff := got[k].Zone()
+                                               require.Equalf(t, wantOff, 
gotOff, "slice [%d:%d] row %d offset", start, end, k)
+                                       }
+                                       sliced.Release()
+                               }
+                       }
+               })
+       }
+}
+
+func TestTimestampWithOffsetSubHourNegativeZone(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       // A negative offset smaller than one hour must keep its sign in the 
zone
+       // name (regression: -30 minutes formatted as "UTC+00:30" instead of 
"UTC-00:30").
+       b, err := extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, 
arrow.PrimitiveTypes.Int16)
+       require.NoError(t, err)
+       b.Append(testDate0.In(time.FixedZone("UTC-00:30", -30*60)))
+       arr := b.NewArray().(*extensions.TimestampWithOffsetArray)
+       defer arr.Release()
+
+       name, off := arr.Value(0).Zone()
+       require.Equal(t, -30*60, off)
+       require.Equal(t, "UTC-00:30", name)
+}
+
+func TestTimestampWithOffsetExtensionRecordBuilder(t *testing.T) {
+       for name, offsetType := range allAllowedOffsetTypes {
+               t.Run(name, func(t *testing.T) {
+                       dataType, _ := 
extensions.NewTimestampWithOffsetTypeCustomOffset(testTimeUnit, offsetType)
+                       schema := arrow.NewSchema([]arrow.Field{
+                               {
+                                       Name:     "timestamp_with_offset",
+                                       Nullable: true,
+                                       Type:     dataType,
+                               },
+                       }, nil)
+                       builder := 
array.NewRecordBuilder(memory.DefaultAllocator, schema)
+                       defer builder.Release()
+
+                       fieldBuilder := 
builder.Field(0).(*extensions.TimestampWithOffsetBuilder)
+
+                       // append a simple time.Time
+                       fieldBuilder.Append(testDate0)
+
+                       // append the epoch
+                       fieldBuilder.Append(epoch)
+
+                       // append a null and 2 time.Time all at once
+                       values := []time.Time{
+                               time.Unix(0, 0).In(time.UTC),
+                               testDate1,
+                               testDate2,
+                       }
+                       valids := []bool{false, true, true}
+                       fieldBuilder.AppendValues(values, valids)
+
+                       // append a value from RFC3339 string
+                       
fieldBuilder.AppendValueFromString(testDate0.Format(time.RFC3339))
+
+                       // append value formatted in a different string layout
+                       fieldBuilder.Layout = time.RFC3339Nano
+                       
fieldBuilder.AppendValueFromString(testDate1.Format(time.RFC3339Nano))
+
+                       record := builder.NewRecordBatch()
+
+                       // Record batch should JSON-encode values containing 
per-row timezone info
+                       json, err := record.MarshalJSON()
+                       require.NoError(t, err)
+                       expect := 
`[{"timestamp_with_offset":"2025-01-01T00:00:00Z"}
+,{"timestamp_with_offset":"1970-01-01T00:00:00Z"}
+,{"timestamp_with_offset":null}
+,{"timestamp_with_offset":"2024-12-31T15:30:00-08:30"}
+,{"timestamp_with_offset":"2025-01-01T11:00:00+11:00"}
+,{"timestamp_with_offset":"2025-01-01T00:00:00Z"}
+,{"timestamp_with_offset":"2024-12-31T15:30:00-08:30"}
+]`
+                       require.Equal(t, expect, string(json))
+
+                       // Record batch roundtrip to JSON should work
+                       roundtripped, _, err := 
array.RecordFromJSON(memory.DefaultAllocator, schema, bytes.NewReader(json))
+                       require.NoError(t, err)
+                       defer roundtripped.Release()
+                       require.Equal(t, schema, roundtripped.Schema())
+                       // FIXME: ideally array.RecordEqual would succeed after 
roundtripping.
+                       // It currently doesn't because it compares the inner 
non-nullable struct
+                       // child validity bitmaps, which differ between builder 
output and JSON
+                       // reader output. That inner-nullability parity is 
decoupled from this
+                       // type (tracked in apache/arrow-go#918), so for now 
avoid RecordEqual and
+                       // assert only what this type guarantees: per-row 
instant, offset, validity.
+                       origArr := 
record.Column(0).(*extensions.TimestampWithOffsetArray)
+                       gotArr := 
roundtripped.Column(0).(*extensions.TimestampWithOffsetArray)
+                       require.Equal(t, origArr.Len(), gotArr.Len())
+                       for i := 0; i < origArr.Len(); i++ {
+                               require.Equalf(t, origArr.IsNull(i), 
gotArr.IsNull(i), "validity mismatch at row %d", i)
+                               if origArr.IsValid(i) {
+                                       want, got := origArr.Value(i), 
gotArr.Value(i)
+                                       require.Truef(t, want.Equal(got), 
"instant mismatch at row %d: %s vs %s", i, want, got)
+                                       _, wantOff := want.Zone()
+                                       _, gotOff := got.Zone()
+                                       require.Equalf(t, wantOff, gotOff, 
"offset mismatch at row %d", i)
+                               }
+                       }
+               })
+       }
+}
+
+func TestTimestampWithOffsetTypeBatchIPCRoundTrip(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       for name, offsetType := range allAllowedOffsetTypes {
+               t.Run(name, func(t *testing.T) {
+                       builder, _ := 
extensions.NewTimestampWithOffsetBuilder(mem, testTimeUnit, offsetType)
+                       builder.Append(testDate0)
+                       builder.AppendNull()
+                       builder.Append(testDate1)
+                       builder.Append(testDate2)
+                       builder.Append(epoch)
+                       arr := builder.NewArray()
+                       defer arr.Release()
+
+                       typ, _ := 
extensions.NewTimestampWithOffsetTypeCustomOffset(testTimeUnit, offsetType)
+
+                       batch := 
array.NewRecordBatch(arrow.NewSchema([]arrow.Field{{Name: 
"timestamp_with_offset", Type: typ, Nullable: true}}, nil), []arrow.Array{arr}, 
-1)
+                       defer batch.Release()
+
+                       var written arrow.RecordBatch
+                       {
+                               var buf bytes.Buffer
+                               wr := ipc.NewWriter(&buf, 
ipc.WithSchema(batch.Schema()))
+                               require.NoError(t, wr.Write(batch))
+                               require.NoError(t, wr.Close())
+
+                               rdr, err := ipc.NewReader(&buf)
+                               require.NoError(t, err)
+                               written, err = rdr.Read()
+                               require.NoError(t, err)
+                               written.Retain()
+                               defer written.Release()
+                               rdr.Release()
+                       }
+
+                       assert.Truef(t, batch.Schema().Equal(written.Schema()), 
"expected: %s\n\ngot: %s",
+                               batch.Schema(), written.Schema())
+
+                       assert.Truef(t, array.RecordEqual(batch, written), 
"expected: %s\n\ngot: %s",
+                               batch, written)
+               })
+       }
+}

Reply via email to