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 b21e0c5a refactor(arrow/avro): migrate from hamba/avro to twmb/avro
(#830)
b21e0c5a is described below
commit b21e0c5a3f308459e167494af1f4e42880b34e3e
Author: Tomáš Karela Procházka <[email protected]>
AuthorDate: Wed Aug 5 17:23:15 2026 +0200
refactor(arrow/avro): migrate from hamba/avro to twmb/avro (#830)
Migrates `arrow/avro` from `hamba/avro/v2` to `twmb/avro`.
- Simplifies union parsing: twmb hands union values to the decoder directly,
so the reader no longer unwraps `{"long.timestamp-millis": v}` shapes.
- Adds `timestamp-nanos` / `local-timestamp-nanos` logical types.
- `fixed(16)` annotated `logicalType: "uuid"` now decodes to
`extensions.UUIDType` instead of `fixed_size_binary[16]`.
- Named-type references resolve against fully-qualified names; an ambiguous
bare reference defined under multiple namespaces now errors.
- The top-level schema must be a record.
- Appender errors for map values and list items now propagate out of
`loadDatum`.
User-facing changes:
- `ArrowSchemaFromAvro` is deprecated; use
`ArrowSchemaFromAvroJSON(string)`.
- Custom decoders registered with hamba's global registry are no longer
applied.
- Ambiguous bare named-type references now error.
Closes #813
---
arrow/avro/loader.go | 3 +-
arrow/avro/reader.go | 38 ++--
arrow/avro/reader_test.go | 384 ++++++++++++++++++++++--------------
arrow/avro/reader_types.go | 403 ++++++++++++--------------------------
arrow/avro/schema.go | 365 ++++++++++++++++++++++------------
arrow/avro/schema_test.go | 134 +++++++++++--
arrow/avro/testdata/alltypes.avsc | 23 +++
arrow/avro/testdata/testdata.go | 268 ++++++++++++++++---------
go.mod | 2 +-
go.sum | 4 +-
10 files changed, 951 insertions(+), 673 deletions(-)
diff --git a/arrow/avro/loader.go b/arrow/avro/loader.go
index a7199e66..fa97c426 100644
--- a/arrow/avro/loader.go
+++ b/arrow/avro/loader.go
@@ -24,7 +24,7 @@ import (
func (r *OCFReader) decodeOCFToChan() {
defer close(r.avroChan)
- for r.r.HasNext() {
+ for {
select {
case <-r.readerCtx.Done():
r.err = fmt.Errorf("avro decoding cancelled, %d records
read", r.avroDatumCount)
@@ -34,7 +34,6 @@ func (r *OCFReader) decodeOCFToChan() {
err := r.r.Decode(&datum)
if err != nil {
if errors.Is(err, io.EOF) {
- r.err = nil
return
}
r.err = err
diff --git a/arrow/avro/reader.go b/arrow/avro/reader.go
index db6de627..be1a926b 100644
--- a/arrow/avro/reader.go
+++ b/arrow/avro/reader.go
@@ -17,6 +17,7 @@
package avro
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -27,10 +28,9 @@ import (
"github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/internal/debug"
"github.com/apache/arrow-go/v18/arrow/memory"
- "github.com/hamba/avro/v2/ocf"
"github.com/tidwall/sjson"
-
- avro "github.com/hamba/avro/v2"
+ "github.com/twmb/avro"
+ "github.com/twmb/avro/ocf"
)
var ErrMismatchFields = errors.New("arrow/avro: number of records mismatch")
@@ -47,9 +47,9 @@ type schemaEdit struct {
value any
}
-// Reader wraps goavro/OCFReader and creates array.RecordBatches from a schema.
+// OCFReader reads Avro OCF files and exposes them as array.RecordBatches.
type OCFReader struct {
- r *ocf.Decoder
+ r *ocf.Reader
avroSchema string
avroSchemaEdits []schemaEdit
schema *arrow.Schema
@@ -82,7 +82,7 @@ type OCFReader struct {
// NewReader returns a reader that reads from an Avro OCF file and creates
// arrow.RecordBatches from the converted avro data.
func NewOCFReader(r io.Reader, opts ...Option) (*OCFReader, error) {
- ocfr, err := ocf.NewDecoder(r)
+ ocfr, err := ocf.NewReader(r)
if err != nil {
return nil, fmt.Errorf("%w: could not create avro ocfreader",
arrow.ErrInvalid)
}
@@ -108,22 +108,20 @@ func NewOCFReader(r io.Reader, opts ...Option)
(*OCFReader, error) {
}
rr.avroSchema = schema.String()
if len(rr.avroSchemaEdits) > 0 {
- // execute schema edits
for _, e := range rr.avroSchemaEdits {
err := rr.editAvroSchema(e)
if err != nil {
return nil, fmt.Errorf("%w: could not edit avro
schema", arrow.ErrInvalid)
}
}
- // validate edited schema
- schema, err = avro.Parse(rr.avroSchema)
- if err != nil {
- return nil, fmt.Errorf("%w: could not parse modified
avro schema", arrow.ErrInvalid)
- }
}
- rr.schema, err = ArrowSchemaFromAvro(schema)
+ rr.schema, err = ArrowSchemaFromAvroJSON(rr.avroSchema)
if err != nil {
- return nil, fmt.Errorf("%w: could not convert avro schema",
arrow.ErrInvalid)
+ msg := "could not convert avro schema"
+ if len(rr.avroSchemaEdits) > 0 {
+ msg = "could not parse modified avro schema"
+ }
+ return nil, fmt.Errorf("%w: %s: %w", arrow.ErrInvalid, msg, err)
}
if rr.mem == nil {
rr.mem = memory.DefaultAllocator
@@ -147,7 +145,7 @@ func NewOCFReader(r io.Reader, opts ...Option) (*OCFReader,
error) {
func (rr *OCFReader) Reuse(r io.Reader, opts ...Option) error {
rr.Close()
rr.err = nil
- ocfr, err := ocf.NewDecoder(r)
+ ocfr, err := ocf.NewReader(r)
if err != nil {
return fmt.Errorf("%w: could not create avro ocfreader",
arrow.ErrInvalid)
}
@@ -155,7 +153,15 @@ func (rr *OCFReader) Reuse(r io.Reader, opts ...Option)
error {
if err != nil {
return fmt.Errorf("%w: could not parse avro header",
arrow.ErrInvalid)
}
- if rr.avroSchema != schema.String() {
+ // Compare the schemas by their parsing canonical form rather than the
raw
+ // JSON so that semantically identical schemas differing only in
+ // whitespace, field ordering of non-essential attributes, or formatting
+ // are still accepted for reuse.
+ current, err := avro.Parse(rr.avroSchema)
+ if err != nil {
+ return fmt.Errorf("%w: could not parse current avro schema",
arrow.ErrInvalid)
+ }
+ if !bytes.Equal(current.Canonical(), schema.Canonical()) {
return fmt.Errorf("%w: avro schema mismatch", arrow.ErrInvalid)
}
diff --git a/arrow/avro/reader_test.go b/arrow/avro/reader_test.go
index dece6883..726011ee 100644
--- a/arrow/avro/reader_test.go
+++ b/arrow/avro/reader_test.go
@@ -28,10 +28,11 @@ import (
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/avro/testdata"
+ "github.com/apache/arrow-go/v18/arrow/extensions"
"github.com/apache/arrow-go/v18/arrow/memory"
- hamba "github.com/hamba/avro/v2"
- "github.com/hamba/avro/v2/ocf"
"github.com/stretchr/testify/assert"
+ "github.com/twmb/avro"
+ "github.com/twmb/avro/ocf"
)
func TestReader(t *testing.T) {
@@ -131,6 +132,10 @@ func TestReader(t *testing.T) {
Name: "uuidField",
Type: arrow.BinaryTypes.String,
},
+ {
+ Name: "fixedUuidField",
+ Type: extensions.NewUUIDType(),
+ },
{
Name: "timemillis",
Type: arrow.FixedWidthTypes.Time32ms,
@@ -155,6 +160,14 @@ func TestReader(t *testing.T) {
Name: "localtimestampmicros",
Type: &arrow.TimestampType{Unit:
arrow.Microsecond},
},
+ {
+ Name: "timestampnanos",
+ Type:
arrow.FixedWidthTypes.Timestamp_ns,
+ },
+ {
+ Name: "localtimestampnanos",
+ Type: &arrow.TimestampType{Unit:
arrow.Nanosecond},
+ },
{
Name: "duration",
Type:
arrow.FixedWidthTypes.MonthDayNanoInterval,
@@ -179,20 +192,13 @@ func TestReader(t *testing.T) {
t.Fatal(err)
}
r := new(OCFReader)
- r.avroSchema = schema.String()
+ r.avroSchema = schema
r.editAvroSchema(schemaEdit{method: "delete", path:
"fields.0"})
- schema, err = hamba.Parse(r.avroSchema)
+ got, err := ArrowSchemaFromAvroJSON(r.avroSchema)
if err != nil {
t.Fatalf("%v: could not parse modified avro
schema", arrow.ErrInvalid)
}
- got, err := ArrowSchemaFromAvro(schema)
- if err != nil {
- t.Fatalf("%v", err)
- }
assert.Equal(t, want.String(), got.String())
- if fmt.Sprintf("%+v", want.String()) !=
fmt.Sprintf("%+v", got.String()) {
- t.Fatalf("got=%v,\n want=%v", got.String(),
want.String())
- }
})
t.Run("ShouldLoadExpectedRecords", func(t *testing.T) {
@@ -212,7 +218,7 @@ func TestReader(t *testing.T) {
exists := ar.Next()
if ar.Err() != nil {
- t.Error("failed to read next record: %w",
ar.Err())
+ t.Errorf("failed to read next record: %v",
ar.Err())
}
if !exists {
t.Error("no record exists")
@@ -232,86 +238,56 @@ func TestReader(t *testing.T) {
}
}
-// TestOCFReaderBytesValues exercises avro `bytes` fields, both plain and as a
-// ["null","bytes"] union: hamba hands the decoded value to the appenders as a
-// bare []byte, which previously fell into appendBinaryData's fmt fallback and
-// appended the formatted text (e.g. "[1 2 3]") instead of the payload.
-func TestOCFReaderBytesValues(t *testing.T) {
- schema := `{
- "type": "record",
- "name": "rec",
- "fields": [
- {"name": "plain", "type": "bytes"},
- {"name": "nullable", "type": ["null", "bytes"]}
- ]
- }`
- payload := []byte{0x00, 0x01, 0xfe, 0xff}
-
- var buf bytes.Buffer
- enc, err := ocf.NewEncoder(schema, &buf)
- assert.NoError(t, err)
- assert.NoError(t, enc.Encode(map[string]any{
- "plain": payload,
- "nullable": map[string]any{"bytes": payload},
- }))
- assert.NoError(t, enc.Encode(map[string]any{
- "plain": []byte{},
- "nullable": nil,
- }))
- assert.NoError(t, enc.Close())
-
- ar, err := NewOCFReader(bytes.NewReader(buf.Bytes()), WithChunk(-1))
- assert.NoError(t, err)
- defer ar.Close()
-
- assert.True(t, ar.Next())
- assert.NoError(t, ar.Err())
- rec := ar.RecordBatch()
-
- plain := rec.Column(0).(*array.Binary)
- assert.Equal(t, payload, plain.Value(0))
- assert.Equal(t, []byte{}, plain.Value(1))
-
- nullable := rec.Column(1).(*array.Binary)
- assert.Equal(t, payload, nullable.Value(0))
- assert.True(t, nullable.IsNull(1))
-}
-
+// A nullable logical timestamp must decode to a value rather than a null: the
+// union branch carries the logical type, so the reader has to honour it on the
+// branch and not just on a bare long.
func TestOCFReaderNullableTimestamps(t *testing.T) {
tests := []struct {
logicalType string
- branch string
typ *arrow.TimestampType
}{
- {"timestamp-millis", "long.timestamp-millis",
arrow.FixedWidthTypes.Timestamp_ms.(*arrow.TimestampType)},
- {"timestamp-micros", "long.timestamp-micros",
arrow.FixedWidthTypes.Timestamp_us.(*arrow.TimestampType)},
- {"local-timestamp-millis", "long.local-timestamp-millis",
&arrow.TimestampType{Unit: arrow.Millisecond}},
- {"local-timestamp-micros", "long.local-timestamp-micros",
&arrow.TimestampType{Unit: arrow.Microsecond}},
+ {"timestamp-millis",
arrow.FixedWidthTypes.Timestamp_ms.(*arrow.TimestampType)},
+ {"timestamp-micros",
arrow.FixedWidthTypes.Timestamp_us.(*arrow.TimestampType)},
+ {"timestamp-nanos",
arrow.FixedWidthTypes.Timestamp_ns.(*arrow.TimestampType)},
+ {"local-timestamp-millis", &arrow.TimestampType{Unit:
arrow.Millisecond}},
+ {"local-timestamp-micros", &arrow.TimestampType{Unit:
arrow.Microsecond}},
+ {"local-timestamp-nanos", &arrow.TimestampType{Unit:
arrow.Nanosecond}},
}
+ // Decoding must not be influenced by the machine's zone: a
local-timestamp
+ // carries no zone at all, so a non-UTC TZ must not shift the value the
+ // reader produces.
+ origLocal := time.Local
+ time.Local = time.FixedZone("test", 3*60*60)
+ t.Cleanup(func() { time.Local = origLocal })
+
for _, tt := range tests {
t.Run(tt.logicalType, func(t *testing.T) {
- schema := fmt.Sprintf(`{
+ schemaJSON := fmt.Sprintf(`{
"type": "record",
"name": "event",
"fields": [{
"name": "started_at",
- "type": [
- "null",
- {"type": "long", "logicalType":
%q}
- ]
+ "type": ["null", {"type": "long",
"logicalType": %q}]
}]
}`, tt.logicalType)
- value := time.Date(2026, 7, 13, 14, 15, 16, 123456000,
time.Local)
+ schema, err := avro.Parse(schemaJSON)
+ assert.NoError(t, err)
+
+ // The value is given in UTC so that the encoder's own
+ // normalisation is a no-op and the assertion below is
about what
+ // the reader decodes, not about how twmb chose to
write it.
+ value := time.Date(2026, 7, 13, 14, 15, 16, 123456789,
time.UTC)
var buf bytes.Buffer
- enc, err := ocf.NewEncoder(schema, &buf)
+ // WithSchema keeps the original JSON in the OCF
header; the
+ // default is the parsing canonical form, which by spec
drops the
+ // logical types this test is about.
+ w, err := ocf.NewWriter(&buf, schema,
ocf.WithSchema(schemaJSON))
assert.NoError(t, err)
- assert.NoError(t, enc.Encode(map[string]any{
- "started_at": map[string]any{tt.branch: value},
- }))
- assert.NoError(t,
enc.Encode(map[string]any{"started_at": nil}))
- assert.NoError(t, enc.Close())
+ assert.NoError(t, w.Encode(map[string]any{"started_at":
value}))
+ assert.NoError(t, w.Encode(map[string]any{"started_at":
nil}))
+ assert.NoError(t, w.Close())
ar, err := NewOCFReader(bytes.NewReader(buf.Bytes()),
WithChunk(-1))
assert.NoError(t, err)
@@ -328,91 +304,19 @@ func TestOCFReaderNullableTimestamps(t *testing.T) {
assert.True(t, values.IsNull(1))
assert.Equal(t, 1, values.NullN())
- wantTime := value
- if tt.typ.TimeZone == "" {
- wantTime = time.Date(value.Year(),
value.Month(), value.Day(), value.Hour(), value.Minute(), value.Second(),
value.Nanosecond(), time.UTC)
- }
- wantValue, err := arrow.TimestampFromTime(wantTime,
tt.typ.Unit)
+ wantValue, err := arrow.TimestampFromTime(value,
tt.typ.Unit)
assert.NoError(t, err)
assert.Equal(t, wantValue, values.Value(0))
- assert.Equal(t, wantValue.ToTime(tt.typ.Unit),
values.Value(0).ToTime(tt.typ.Unit))
})
}
}
-func TestAppendTimestampDataUnionBranches(t *testing.T) {
- value := time.Date(2026, 7, 13, 14, 15, 16, 123456789,
time.FixedZone("source", 3*60*60))
- localValue := time.Date(2026, 7, 13, 14, 15, 16, 123456789, time.UTC)
- tests := []struct {
- name string
- branch string
- typ *arrow.TimestampType
- want time.Time
- }{
- {"timestamp-millis", "long.timestamp-millis",
arrow.FixedWidthTypes.Timestamp_ms.(*arrow.TimestampType), value},
- {"timestamp-micros", "long.timestamp-micros",
arrow.FixedWidthTypes.Timestamp_us.(*arrow.TimestampType), value},
- {"local-timestamp-millis", "long.local-timestamp-millis",
&arrow.TimestampType{Unit: arrow.Millisecond}, localValue},
- {"local-timestamp-micros", "long.local-timestamp-micros",
&arrow.TimestampType{Unit: arrow.Microsecond}, localValue},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- b := array.NewTimestampBuilder(memory.DefaultAllocator,
tt.typ)
- defer b.Release()
- appendTimestampData(b, map[string]any{tt.branch: value})
-
- values := b.NewTimestampArray()
- defer values.Release()
- want, err := arrow.TimestampFromTime(tt.want,
tt.typ.Unit)
- assert.NoError(t, err)
- assert.False(t, values.IsNull(0))
- assert.Equal(t, want, values.Value(0))
- })
- }
-
- b := array.NewTimestampBuilder(memory.DefaultAllocator,
arrow.FixedWidthTypes.Timestamp_us.(*arrow.TimestampType))
- defer b.Release()
- appendTimestampData(b, map[string]any{"long": int64(42)})
- appendTimestampData(b, nil)
- values := b.NewTimestampArray()
- defer values.Release()
- assert.Equal(t, arrow.Timestamp(42), values.Value(0))
- assert.True(t, values.IsNull(1))
-}
-
-// Types outside what the hamba decoder produces must error rather than append
-// a fmt-formatted rendering of the value.
-func TestAppendBinaryAndStringDataUnexpectedTypes(t *testing.T) {
- bb := array.NewBinaryBuilder(memory.DefaultAllocator,
arrow.BinaryTypes.Binary)
- defer bb.Release()
-
- assert.NoError(t, appendBinaryData(bb, []byte{0x01}))
- assert.NoError(t, appendBinaryData(bb, nil))
- assert.NoError(t, appendBinaryData(bb, map[string]any{"bytes":
[]byte{0x02}}))
- assert.ErrorContains(t, appendBinaryData(bb, 42), "unexpected type int")
- assert.ErrorContains(t, appendBinaryData(bb, map[string]any{"bytes":
"text"}), "unexpected type string")
- assert.Equal(t, 3, bb.Len())
-
- sb := array.NewStringBuilder(memory.DefaultAllocator)
- defer sb.Release()
-
- assert.NoError(t, appendStringData(sb, "ok"))
- assert.NoError(t, appendStringData(sb, []byte("ok")))
- assert.NoError(t, appendStringData(sb, nil))
- assert.NoError(t, appendStringData(sb, map[string]any{"string": "ok"}))
- assert.ErrorContains(t, appendStringData(sb, 42), "unexpected type int")
- assert.ErrorContains(t, appendStringData(sb, map[string]any{"string":
42}), "unexpected type int")
- assert.Equal(t, 4, sb.Len())
-}
-
-// loadDatum must surface appender errors from nested paths (map values,
-// list items), not only from top-level and struct fields.
+// loadDatum must surface appender errors from nested paths (map values and
+// list items), not just from top-level scalar fields.
func TestLoadDatumPropagatesNestedAppendErrors(t *testing.T) {
newLoader := func(t *testing.T, avroSchema string) (*dataLoader,
*array.RecordBuilder) {
t.Helper()
- schema, err := hamba.Parse(avroSchema)
- assert.NoError(t, err)
- arrowSchema, err := ArrowSchemaFromAvro(schema)
+ arrowSchema, err := ArrowSchemaFromAvroJSON(avroSchema)
assert.NoError(t, err)
bld := array.NewRecordBuilder(memory.DefaultAllocator,
arrowSchema)
pos := newFieldPos()
@@ -429,7 +333,7 @@ func TestLoadDatumPropagatesNestedAppendErrors(t
*testing.T) {
{"name":"m","type":{"type":"map","values":"bytes"}}]}`)
defer bld.Release()
assert.NoError(t, ldr.loadDatum(map[string]any{"m":
map[string]any{"k": []byte{0x01}}}))
- assert.ErrorContains(t, ldr.loadDatum(map[string]any{"m":
map[string]any{"k": 42}}), "unexpected type int")
+ assert.ErrorContains(t, ldr.loadDatum(map[string]any{"m":
map[string]any{"k": 42}}), "unsupported value of type int")
})
t.Run("list item", func(t *testing.T) {
@@ -437,6 +341,182 @@ func TestLoadDatumPropagatesNestedAppendErrors(t
*testing.T) {
{"name":"l","type":{"type":"array","items":"bytes"}}]}`)
defer bld.Release()
assert.NoError(t, ldr.loadDatum(map[string]any{"l":
[]any{[]byte{0x01}}}))
- assert.ErrorContains(t, ldr.loadDatum(map[string]any{"l":
[]any{42}}), "unexpected type int")
+ assert.ErrorContains(t, ldr.loadDatum(map[string]any{"l":
[]any{42}}), "unsupported value of type int")
})
}
+
+// writeOCF encodes data as an OCF file using schemaJSON verbatim in the
+// header, so logical-type annotations survive (the writer would otherwise
+// default to the parsing canonical form, which strips them).
+func writeOCF(t *testing.T, schemaJSON string, data ...any) []byte {
+ t.Helper()
+ schema, err := avro.Parse(schemaJSON)
+ assert.NoError(t, err)
+
+ var buf bytes.Buffer
+ w, err := ocf.NewWriter(&buf, schema, ocf.WithSchema(schemaJSON))
+ assert.NoError(t, err)
+ for _, d := range data {
+ assert.NoError(t, w.Encode(d))
+ }
+ assert.NoError(t, w.Close())
+ return buf.Bytes()
+}
+
+// Only ["null", T] unions map onto Arrow's nullability. Anything else has no
+// faithful representation, so it must be reported rather than silently
+// resolved to one of the branches.
+func TestUnsupportedUnionReportsError(t *testing.T) {
+ tests := []struct {
+ name string
+ union string
+ }{
+ {"no null branch", `["int", "string"]`},
+ {"two non-null branches", `["null", "int", "string"]`},
+ {"named branches", `["null",
{"type":"record","name":"A","fields":[]},
{"type":"record","name":"B","fields":[]}]`},
+ {"null only", `["null"]`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := ArrowSchemaFromAvroJSON(fmt.Sprintf(
+
`{"type":"record","name":"r","fields":[{"name":"u","type":%s}]}`, tt.union))
+ assert.ErrorContains(t, err, "unsupported avro union")
+ })
+ }
+
+ t.Run("nested in array", func(t *testing.T) {
+ _, err :=
ArrowSchemaFromAvroJSON(`{"type":"record","name":"r","fields":[
+
{"name":"a","type":{"type":"array","items":["int","string"]}}]}`)
+ assert.ErrorContains(t, err, "unsupported avro union")
+ })
+}
+
+// Reuse accepts a second file whose schema is semantically identical, and
+// rejects one whose schema differs.
+func TestOCFReaderReuse(t *testing.T) {
+ const schemaJSON = `{"type":"record","name":"r","fields":[
+ {"name":"id","type":"int"},
+ {"name":"name","type":"string"}]}`
+ // Same schema, but reformatted and with a doc attribute: the parsing
+ // canonical form is unchanged, so this must still be reusable.
+ const equivalentJSON =
`{"name":"r","type":"record","doc":"reformatted","fields":[{"name":"id","type":"int"},{"name":"name","type":"string"}]}`
+ const otherJSON =
`{"type":"record","name":"r","fields":[{"name":"id","type":"long"}]}`
+
+ first := writeOCF(t, schemaJSON, map[string]any{"id": int32(1), "name":
"one"})
+ second := writeOCF(t, equivalentJSON, map[string]any{"id": int32(2),
"name": "two"})
+ other := writeOCF(t, otherJSON, map[string]any{"id": int64(3)})
+
+ readIDs := func(t *testing.T, ar *OCFReader) []int32 {
+ t.Helper()
+ var got []int32
+ for ar.Next() {
+ assert.NoError(t, ar.Err())
+ col := ar.RecordBatch().Column(0).(*array.Int32)
+ got = append(got, col.Int32Values()...)
+ }
+ assert.NoError(t, ar.Err())
+ return got
+ }
+
+ ar, err := NewOCFReader(bytes.NewReader(first), WithChunk(-1))
+ assert.NoError(t, err)
+ defer ar.Close()
+ assert.Equal(t, []int32{1}, readIDs(t, ar))
+
+ schemaBefore := ar.Schema()
+ assert.NoError(t, ar.Reuse(bytes.NewReader(second), WithChunk(-1)))
+ assert.Equal(t, []int32{2}, readIDs(t, ar))
+ // Reuse keeps the builders, so the Arrow schema must be untouched.
+ assert.Equal(t, schemaBefore, ar.Schema())
+
+ assert.ErrorContains(t, ar.Reuse(bytes.NewReader(other)), "avro schema
mismatch")
+}
+
+// A positive chunk size splits the datums into batches of that many rows, with
+// a shorter final batch for the remainder.
+func TestOCFReaderWithChunk(t *testing.T) {
+ const schemaJSON =
`{"type":"record","name":"r","fields":[{"name":"id","type":"int"}]}`
+ const rows = 7
+
+ data := make([]any, rows)
+ for i := range data {
+ data[i] = map[string]any{"id": int32(i)}
+ }
+ fixture := writeOCF(t, schemaJSON, data...)
+
+ for _, chunk := range []int{1, 3, rows, rows + 1} {
+ t.Run(fmt.Sprintf("chunk=%d", chunk), func(t *testing.T) {
+ ar, err := NewOCFReader(bytes.NewReader(fixture),
WithChunk(chunk))
+ assert.NoError(t, err)
+ defer ar.Close()
+
+ var lens []int
+ var ids []int32
+ for ar.Next() {
+ assert.NoError(t, ar.Err())
+ rec := ar.RecordBatch()
+ lens = append(lens, int(rec.NumRows()))
+ ids = append(ids,
rec.Column(0).(*array.Int32).Int32Values()...)
+ }
+ assert.NoError(t, ar.Err())
+
+ var want []int
+ for left := rows; left > 0; left -= chunk {
+ want = append(want, min(left, chunk))
+ }
+ assert.Equal(t, want, lens)
+ assert.Equal(t, []int32{0, 1, 2, 3, 4, 5, 6}, ids)
+ assert.Equal(t, int64(rows), ar.OCFRecordsReadCount())
+ })
+ }
+}
+
+// A record made up only of enum columns must come back as dictionary arrays
+// carrying the Avro symbols, including through a ["null", enum] union.
+func TestOCFReaderEnumOnlyColumns(t *testing.T) {
+ const schemaJSON = `{"type":"record","name":"r","fields":[
+
{"name":"suit","type":{"type":"enum","name":"Suit","symbols":["hearts","spades"]}},
+
{"name":"rank","type":["null",{"type":"enum","name":"Rank","symbols":["low","high"]}]}]}`
+
+ fixture := writeOCF(t, schemaJSON,
+ map[string]any{"suit": "spades", "rank": "high"},
+ map[string]any{"suit": "hearts", "rank": nil},
+ )
+
+ ar, err := NewOCFReader(bytes.NewReader(fixture), WithChunk(-1))
+ assert.NoError(t, err)
+ defer ar.Close()
+
+ suit := ar.Schema().Field(0)
+ assert.Equal(t, &arrow.DictionaryType{
+ IndexType: arrow.PrimitiveTypes.Uint8,
+ ValueType: arrow.BinaryTypes.String,
+ }, suit.Type)
+ assert.False(t, suit.Nullable)
+ symbols, ok := suit.Metadata.GetValue("1")
+ assert.True(t, ok)
+ assert.Equal(t, "spades", symbols)
+ assert.True(t, ar.Schema().Field(1).Nullable)
+
+ assert.True(t, ar.Next())
+ assert.NoError(t, ar.Err())
+ rec := ar.RecordBatch()
+ assert.Equal(t, int64(2), rec.NumRows())
+
+ values := func(col arrow.Array) []string {
+ d := col.(*array.Dictionary)
+ dict := d.Dictionary().(*array.String)
+ out := make([]string, d.Len())
+ for i := range out {
+ if d.IsNull(i) {
+ out[i] = "<null>"
+ continue
+ }
+ out[i] = dict.Value(d.GetValueIndex(i))
+ }
+ return out
+ }
+ assert.Equal(t, []string{"spades", "hearts"}, values(rec.Column(0)))
+ assert.Equal(t, []string{"high", "<null>"}, values(rec.Column(1)))
+}
diff --git a/arrow/avro/reader_types.go b/arrow/avro/reader_types.go
index 14632d02..c2298e49 100644
--- a/arrow/avro/reader_types.go
+++ b/arrow/avro/reader_types.go
@@ -17,8 +17,6 @@
package avro
import (
- "bytes"
- "encoding/binary"
"errors"
"fmt"
"math/big"
@@ -31,7 +29,7 @@ import (
"github.com/apache/arrow-go/v18/arrow/decimal256"
"github.com/apache/arrow-go/v18/arrow/extensions"
"github.com/apache/arrow-go/v18/arrow/memory"
- hamba "github.com/hamba/avro/v2"
+ avro "github.com/twmb/avro"
)
type dataLoader struct {
@@ -92,19 +90,10 @@ func (d *dataLoader) drawTree(field *fieldPos) {
// Since array.StructBuilder.AppendNull() will recursively append null to all
of the
// struct's fields, in the case of nil being passed to a struct's builderFunc
it will
// return a ErrNullStructData error to signal that all its sub-fields can be
skipped.
-// filterNullStruct drops ErrNullStructData, which signals a null struct
-// whose sub-fields can be skipped rather than a failure.
-func filterNullStruct(err error) error {
- if err == ErrNullStructData {
- return nil
- }
- return err
-}
-
func (d *dataLoader) loadDatum(data any) error {
if d.list == nil && d.mapField == nil {
if d.mapValue != nil {
- if err :=
filterNullStruct(d.mapValue.appendFunc(data)); err != nil {
+ if err := appendValue(d.mapValue, data); err != nil {
return err
}
}
@@ -192,16 +181,16 @@ func (d *dataLoader) loadDatum(data any) error {
if d.list != nil {
switch dt := data.(type) {
case nil:
- if err :=
filterNullStruct(d.list.appendFunc(dt)); err != nil {
+ if err := appendValue(d.list, dt); err != nil {
return err
}
case []any:
- if err :=
filterNullStruct(d.list.appendFunc(dt)); err != nil {
+ if err := appendValue(d.list, dt); err != nil {
return err
}
for _, e := range dt {
if d.item != nil {
- if err :=
filterNullStruct(d.item.appendFunc(e)); err != nil {
+ if err := appendValue(d.item,
e); err != nil {
return err
}
}
@@ -233,12 +222,12 @@ func (d *dataLoader) loadDatum(data any) error {
}
}
case map[string]any:
- if err :=
filterNullStruct(d.list.appendFunc(dt["array"])); err != nil {
+ if err := appendValue(d.list, dt["array"]); err
!= nil {
return err
}
for _, e := range dt["array"].([]any) {
if d.item != nil {
- if err :=
filterNullStruct(d.item.appendFunc(e)); err != nil {
+ if err := appendValue(d.item,
e); err != nil {
return err
}
}
@@ -263,10 +252,10 @@ func (d *dataLoader) loadDatum(data any) error {
}
}
default:
- if err :=
filterNullStruct(d.list.appendFunc(data)); err != nil {
+ if err := appendValue(d.list, data); err != nil
{
return err
}
- if err :=
filterNullStruct(d.item.appendFunc(dt)); err != nil {
+ if err := appendValue(d.item, dt); err != nil {
return err
}
}
@@ -274,23 +263,25 @@ func (d *dataLoader) loadDatum(data any) error {
if d.mapField != nil {
switch dt := data.(type) {
case nil:
- if err :=
filterNullStruct(d.mapField.appendFunc(dt)); err != nil {
+ if err := appendValue(d.mapField, dt); err !=
nil {
return err
}
case map[string]any:
- if err :=
filterNullStruct(d.mapField.appendFunc(dt)); err != nil {
+ if err := appendValue(d.mapField, dt); err !=
nil {
return err
}
for k, v := range dt {
- if err :=
filterNullStruct(d.mapKey.appendFunc(k)); err != nil {
+ if err := appendValue(d.mapKey, k); err
!= nil {
return err
}
if d.mapValue != nil {
- if err :=
filterNullStruct(d.mapValue.appendFunc(v)); err != nil {
+ if err :=
appendValue(d.mapValue, v); err != nil {
+ return err
+ }
+ } else {
+ if err :=
d.children[0].loadDatum(v); err != nil {
return err
}
- } else if err :=
d.children[0].loadDatum(v); err != nil {
- return err
}
}
}
@@ -299,6 +290,18 @@ func (d *dataLoader) loadDatum(data any) error {
return nil
}
+// appendValue invokes the field's appendFunc and propagates any error, while
+// treating ErrNullStructData as a non-error signal. A struct builder returns
+// ErrNullStructData when it receives nil to indicate that its sub-fields have
+// already been null-filled and can be skipped; that is expected here and must
+// not abort the load.
+func appendValue(f *fieldPos, data any) error {
+ if err := f.appendFunc(data); err != nil && err != ErrNullStructData {
+ return err
+ }
+ return nil
+}
+
func (d *dataLoader) newChild() *dataLoader {
var child = &dataLoader{
depth: d.depth + 1,
@@ -450,8 +453,7 @@ func mapFieldBuilders(b array.Builder, field arrow.Field,
parent *fieldPos) {
case *array.BinaryDictionaryBuilder:
// has metadata for Avro enum symbols
f.appendFunc = func(data interface{}) error {
- appendBinaryDictData(bt, data)
- return nil
+ return appendBinaryDictData(bt, data)
}
// add Avro enum symbols to builder
sb := array.NewStringBuilder(memory.DefaultAllocator)
@@ -462,13 +464,11 @@ func mapFieldBuilders(b array.Builder, field arrow.Field,
parent *fieldPos) {
bt.InsertStringDictValues(sa)
case *array.BooleanBuilder:
f.appendFunc = func(data interface{}) error {
- appendBoolData(bt, data)
- return nil
+ return appendBoolData(bt, data)
}
case *array.Date32Builder:
f.appendFunc = func(data interface{}) error {
- appendDate32Data(bt, data)
- return nil
+ return appendDate32Data(bt, data)
}
case *array.Decimal128Builder:
f.appendFunc = func(data interface{}) error {
@@ -476,11 +476,7 @@ func mapFieldBuilders(b array.Builder, field arrow.Field,
parent *fieldPos) {
if !ok {
return nil
}
- err := appendDecimal128Data(bt, data, typ)
- if err != nil {
- return err
- }
- return nil
+ return appendDecimal128Data(bt, data, typ)
}
case *array.Decimal256Builder:
f.appendFunc = func(data interface{}) error {
@@ -488,54 +484,31 @@ func mapFieldBuilders(b array.Builder, field arrow.Field,
parent *fieldPos) {
if !ok {
return nil
}
- err := appendDecimal256Data(bt, data, typ)
- if err != nil {
- return err
- }
- return nil
+ return appendDecimal256Data(bt, data, typ)
}
case *extensions.UUIDBuilder:
f.appendFunc = func(data interface{}) error {
- switch dt := data.(type) {
- case nil:
- bt.AppendNull()
- case string:
- err := bt.AppendValueFromString(dt)
- if err != nil {
- return err
- }
- case []byte:
- err := bt.AppendValueFromString(string(dt))
- if err != nil {
- return err
- }
- }
- return nil
+ return appendUUIDData(bt, data, field.Name)
}
case *array.FixedSizeBinaryBuilder:
f.appendFunc = func(data interface{}) error {
- appendFixedSizeBinaryData(bt, data)
- return nil
+ return appendFixedSizeBinaryData(bt, data)
}
case *array.Float32Builder:
f.appendFunc = func(data interface{}) error {
- appendFloat32Data(bt, data)
- return nil
+ return appendFloat32Data(bt, data)
}
case *array.Float64Builder:
f.appendFunc = func(data interface{}) error {
- appendFloat64Data(bt, data)
- return nil
+ return appendFloat64Data(bt, data)
}
case *array.Int32Builder:
f.appendFunc = func(data interface{}) error {
- appendInt32Data(bt, data)
- return nil
+ return appendInt32Data(bt, data)
}
case *array.Int64Builder:
f.appendFunc = func(data interface{}) error {
- appendInt64Data(bt, data)
- return nil
+ return appendInt64Data(bt, data)
}
case *array.LargeListBuilder:
vb := bt.ValueBuilder()
@@ -593,8 +566,7 @@ func mapFieldBuilders(b array.Builder, field arrow.Field,
parent *fieldPos) {
}
case *array.MonthDayNanoIntervalBuilder:
f.appendFunc = func(data interface{}) error {
- appendDurationData(bt, data)
- return nil
+ return appendDurationData(bt, data)
}
case *array.StringBuilder:
f.appendFunc = func(data interface{}) error {
@@ -620,131 +592,104 @@ func mapFieldBuilders(b array.Builder, field
arrow.Field, parent *fieldPos) {
}
case *array.Time32Builder:
f.appendFunc = func(data interface{}) error {
- appendTime32Data(bt, data)
- return nil
+ return appendTime32Data(bt, data)
}
case *array.Time64Builder:
f.appendFunc = func(data interface{}) error {
- appendTime64Data(bt, data)
- return nil
+ return appendTime64Data(bt, data)
}
case *array.TimestampBuilder:
f.appendFunc = func(data interface{}) error {
- appendTimestampData(bt, data)
- return nil
+ return appendTimestampData(bt, data)
}
}
}
+// appendUUIDData accepts the two shapes a UUID may arrive as: a [16]byte
+// (fixed(16)+uuid) or a hex-dash string (string+uuid). Other byte lengths
+// are rejected rather than re-interpreted.
+func appendUUIDData(b *extensions.UUIDBuilder, data any, fieldName string)
error {
+ switch dt := data.(type) {
+ case nil:
+ b.AppendNull()
+ case string:
+ return b.AppendValueFromString(dt)
+ case [16]byte:
+ b.AppendBytes(dt)
+ case []byte:
+ if len(dt) != 16 {
+ return fmt.Errorf("avro: %d-byte value cannot be a UUID
for column %q", len(dt), fieldName)
+ }
+ b.AppendBytes([16]byte(dt))
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for UUID
column %q", data, fieldName)
+ }
+ return nil
+}
+
func appendBinaryData(b *array.BinaryBuilder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
case []byte:
b.Append(dt)
- case map[string]any:
- switch ct := dt["bytes"].(type) {
- case nil:
- b.AppendNull()
- case []byte:
- b.Append(ct)
- default:
- return fmt.Errorf("unexpected type %T for avro bytes
union value", ct)
- }
default:
- return fmt.Errorf("unexpected type %T for avro bytes value",
data)
+ return fmt.Errorf("avro: unsupported value of type %T for
Binary column", data)
}
return nil
}
-func appendBinaryDictData(b *array.BinaryDictionaryBuilder, data interface{}) {
+func appendBinaryDictData(b *array.BinaryDictionaryBuilder, data interface{})
error {
switch dt := data.(type) {
case nil:
b.AppendNull()
case string:
- b.AppendString(dt)
- case map[string]any:
- switch v := dt["string"].(type) {
- case nil:
- b.AppendNull()
- case string:
- b.AppendString(v)
+ if err := b.AppendString(dt); err != nil {
+ return fmt.Errorf("avro: enum symbol %q is not in the
dictionary (schema/data mismatch?): %w", dt, err)
}
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Dictionary column", data)
}
+ return nil
}
-func appendBoolData(b *array.BooleanBuilder, data interface{}) {
+func appendBoolData(b *array.BooleanBuilder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
case bool:
b.Append(dt)
- case map[string]any:
- switch v := dt["boolean"].(type) {
- case nil:
- b.AppendNull()
- case bool:
- b.Append(v)
- }
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Boolean column", data)
}
+ return nil
}
-func appendDate32Data(b *array.Date32Builder, data interface{}) {
+func appendDate32Data(b *array.Date32Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case int32:
- b.Append(arrow.Date32(dt))
- case map[string]any:
- switch v := dt["int"].(type) {
- case nil:
- b.AppendNull()
- case int32:
- b.Append(arrow.Date32(v))
- }
case time.Time:
b.Append(arrow.Date32FromTime(dt))
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Date32 column", data)
}
+ return nil
}
func appendDecimal128Data(b *array.Decimal128Builder, data interface{}, typ
arrow.DecimalType) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case []byte:
- buf := bytes.NewBuffer(dt)
- if len(dt) <= 38 {
- var intData int64
- err := binary.Read(buf, binary.BigEndian, &intData)
- if err != nil {
- return err
- }
- b.Append(decimal128.FromI64(intData))
- } else {
- var bigIntData big.Int
-
b.Append(decimal128.FromBigInt(bigIntData.SetBytes(buf.Bytes())))
- }
- case map[string]any:
- buf := bytes.NewBuffer(dt["bytes"].([]byte))
- if len(dt["bytes"].([]byte)) <= 38 {
- var intData int64
- err := binary.Read(buf, binary.BigEndian, &intData)
- if err != nil {
- return err
- }
- b.Append(decimal128.FromI64(intData))
- } else {
- var bigIntData big.Int
-
b.Append(decimal128.FromBigInt(bigIntData.SetBytes(buf.Bytes())))
- }
case *big.Rat:
v := bigRatToBigInt(dt, typ)
-
if v.IsInt64() {
b.Append(decimal128.FromI64(v.Int64()))
} else {
b.Append(decimal128.FromBigInt(v))
}
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Decimal128 column", data)
}
return nil
}
@@ -753,16 +698,10 @@ func appendDecimal256Data(b *array.Decimal256Builder,
data interface{}, typ arro
switch dt := data.(type) {
case nil:
b.AppendNull()
- case []byte:
- var bigIntData big.Int
- buf := bytes.NewBuffer(dt)
-
b.Append(decimal256.FromBigInt(bigIntData.SetBytes(buf.Bytes())))
- case map[string]any:
- var bigIntData big.Int
- buf := bytes.NewBuffer(dt["bytes"].([]byte))
-
b.Append(decimal256.FromBigInt(bigIntData.SetBytes(buf.Bytes())))
case *big.Rat:
b.Append(decimal256.FromBigInt(bigRatToBigInt(dt, typ)))
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Decimal256 column", data)
}
return nil
}
@@ -779,129 +718,88 @@ func bigRatToBigInt(dt *big.Rat, typ arrow.DecimalType)
*big.Int {
// Avro duration logical type annotates Avro fixed type of size 12, which
stores three little-endian
// unsigned integers that represent durations at different granularities of
time. The first stores
// a number in months, the second stores a number in days, and the third
stores a number in milliseconds.
-func appendDurationData(b *array.MonthDayNanoIntervalBuilder, data
interface{}) {
+func appendDurationData(b *array.MonthDayNanoIntervalBuilder, data
interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case []byte:
- dur := new(arrow.MonthDayNanoInterval)
- dur.Months = int32(binary.LittleEndian.Uint16(dt[:3]))
- dur.Days = int32(binary.LittleEndian.Uint16(dt[4:7]))
- dur.Nanoseconds = int64(binary.LittleEndian.Uint32(dt[8:]) *
1000000)
- b.Append(*dur)
- case map[string]any:
- switch dtb := dt["bytes"].(type) {
- case nil:
- b.AppendNull()
- case []byte:
- dur := new(arrow.MonthDayNanoInterval)
- dur.Months = int32(binary.LittleEndian.Uint16(dtb[:3]))
- dur.Days = int32(binary.LittleEndian.Uint16(dtb[4:7]))
- dur.Nanoseconds =
int64(binary.LittleEndian.Uint32(dtb[8:]) * 1000000)
- b.Append(*dur)
- }
- case hamba.LogicalDuration:
+ case avro.Duration:
b.Append(arrow.MonthDayNanoInterval{
Months: int32(dt.Months),
Days: int32(dt.Days),
Nanoseconds: int64(dt.Milliseconds) *
int64(time.Millisecond),
})
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Duration column", data)
}
+ return nil
}
-func appendFixedSizeBinaryData(b *array.FixedSizeBinaryBuilder, data
interface{}) {
+func appendFixedSizeBinaryData(b *array.FixedSizeBinaryBuilder, data
interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
case []byte:
b.Append(dt)
- case map[string]any:
- switch v := dt["bytes"].(type) {
- case nil:
- b.AppendNull()
- case []byte:
- b.Append(v)
- }
default:
+ // fixed(N) may arrive as a Go [N]byte; accept any byte-array
via reflection.
v := reflect.ValueOf(data)
if v.Kind() == reflect.Array && v.Type().Elem().Kind() ==
reflect.Uint8 {
- bytes := make([]byte, v.Len())
- reflect.Copy(reflect.ValueOf(bytes), v)
- b.Append(bytes)
+ buf := make([]byte, v.Len())
+ reflect.Copy(reflect.ValueOf(buf), v)
+ b.Append(buf)
+ return nil
}
+ return fmt.Errorf("avro: unsupported value of type %T for
FixedSizeBinary column", data)
}
+ return nil
}
-func appendFloat32Data(b *array.Float32Builder, data interface{}) {
+func appendFloat32Data(b *array.Float32Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
case float32:
b.Append(dt)
- case map[string]any:
- switch v := dt["float"].(type) {
- case nil:
- b.AppendNull()
- case float32:
- b.Append(v)
- }
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Float32 column", data)
}
+ return nil
}
-func appendFloat64Data(b *array.Float64Builder, data interface{}) {
+func appendFloat64Data(b *array.Float64Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
case float64:
b.Append(dt)
- case map[string]any:
- switch v := dt["double"].(type) {
- case nil:
- b.AppendNull()
- case float64:
- b.Append(v)
- }
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Float64 column", data)
}
+ return nil
}
-func appendInt32Data(b *array.Int32Builder, data interface{}) {
+func appendInt32Data(b *array.Int32Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case int:
- b.Append(int32(dt))
case int32:
b.Append(dt)
- case map[string]any:
- switch v := dt["int"].(type) {
- case nil:
- b.AppendNull()
- case int:
- b.Append(int32(v))
- case int32:
- b.Append(v)
- }
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for Int32
column", data)
}
+ return nil
}
-func appendInt64Data(b *array.Int64Builder, data interface{}) {
+func appendInt64Data(b *array.Int64Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case int:
- b.Append(int64(dt))
case int64:
b.Append(dt)
- case map[string]any:
- switch v := dt["long"].(type) {
- case nil:
- b.AppendNull()
- case int:
- b.Append(int64(v))
- case int64:
- b.Append(v)
- }
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for Int64
column", data)
}
+ return nil
}
func appendStringData(b *array.StringBuilder, data interface{}) error {
@@ -910,91 +808,48 @@ func appendStringData(b *array.StringBuilder, data
interface{}) error {
b.AppendNull()
case string:
b.Append(dt)
- case []byte:
- b.Append(string(dt))
- case map[string]any:
- switch v := dt["string"].(type) {
- case nil:
- b.AppendNull()
- case string:
- b.Append(v)
- default:
- return fmt.Errorf("unexpected type %T for avro string
union value", v)
- }
default:
- return fmt.Errorf("unexpected type %T for avro string value",
data)
+ return fmt.Errorf("avro: unsupported value of type %T for
String column", data)
}
return nil
}
-func appendTime32Data(b *array.Time32Builder, data interface{}) {
+func appendTime32Data(b *array.Time32Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case int32:
- b.Append(arrow.Time32(dt))
- case map[string]any:
- switch v := dt["int"].(type) {
- case nil:
- b.AppendNull()
- case int32:
- b.Append(arrow.Time32(v))
- }
case time.Duration:
b.Append(arrow.Time32(dt.Milliseconds()))
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Time32 column", data)
}
+ return nil
}
-func appendTime64Data(b *array.Time64Builder, data interface{}) {
+func appendTime64Data(b *array.Time64Builder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case int64:
- b.Append(arrow.Time64(dt))
- case map[string]any:
- switch v := dt["long"].(type) {
- case nil:
- b.AppendNull()
- case int64:
- b.Append(arrow.Time64(v))
- }
case time.Duration:
b.Append(arrow.Time64(dt.Microseconds()))
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Time64 column", data)
}
+ return nil
}
-func appendTimestampData(b *array.TimestampBuilder, data interface{}) {
+func appendTimestampData(b *array.TimestampBuilder, data interface{}) error {
switch dt := data.(type) {
case nil:
b.AppendNull()
- case int64:
- b.Append(arrow.Timestamp(dt))
- case map[string]any:
- for branch, value := range dt {
- switch branch {
- case "long",
- "long.timestamp-millis",
- "long.timestamp-micros",
- "long.local-timestamp-millis",
- "long.local-timestamp-micros":
- appendTimestampData(b, value)
- return
- }
- }
- b.AppendNull()
case time.Time:
- tt := b.Type().(*arrow.TimestampType)
- // hamba decodes a local-timestamp logical type into a
time.Time whose wall-clock
- // fields hold the intended value but whose instant is offset
by the decoder's local
- // zone. Arrow stores local (zone-less) timestamps as the wall
clock read in UTC, so
- // reinterpret the fields in UTC to keep the value
zone-independent.
- if tt.TimeZone == "" {
- dt = time.Date(dt.Year(), dt.Month(), dt.Day(),
dt.Hour(), dt.Minute(), dt.Second(), dt.Nanosecond(), time.UTC)
- }
- v, err := arrow.TimestampFromTime(dt, tt.Unit)
+ v, err := arrow.TimestampFromTime(dt,
b.Type().(*arrow.TimestampType).Unit)
if err != nil {
- panic(err)
+ return err
}
b.Append(v)
+ default:
+ return fmt.Errorf("avro: unsupported value of type %T for
Timestamp column", data)
}
+ return nil
}
diff --git a/arrow/avro/schema.go b/arrow/avro/schema.go
index 6523096c..4e1e29d7 100644
--- a/arrow/avro/schema.go
+++ b/arrow/avro/schema.go
@@ -18,6 +18,7 @@
package avro
import (
+ "encoding/json"
"fmt"
"math"
"strconv"
@@ -26,24 +27,46 @@ import (
"github.com/apache/arrow-go/v18/arrow/decimal128"
"github.com/apache/arrow-go/v18/arrow/extensions"
"github.com/apache/arrow-go/v18/internal/utils"
- avro "github.com/hamba/avro/v2"
+ hambaAvro "github.com/hamba/avro/v2"
+ avro "github.com/twmb/avro"
)
+// builtinAvroTypes is the set of Type field values that mean "this SchemaNode
+// is the inline definition of an Avro type." Anything else in node.Type is
+// treated as a named-type reference to a previously-seen record/enum/fixed.
+var builtinAvroTypes = map[string]struct{}{
+ "null": {}, "boolean": {}, "int": {}, "long": {},
+ "float": {}, "double": {}, "bytes": {}, "string": {},
+ "record": {}, "enum": {}, "array": {}, "map": {},
+ "fixed": {}, "union": {},
+}
+
type schemaNode struct {
- name string
- parent *schemaNode
- schema avro.Schema
- union bool
- nullable bool
- childrens []*schemaNode
- arrowField arrow.Field
- schemaCache *avro.SchemaCache
- index, depth int32
+ name string
+ parent *schemaNode
+ node avro.SchemaNode
+ union bool
+ nullable bool
+ childrens []*schemaNode
+ arrowField arrow.Field
+ // namedCache holds record/enum/fixed definitions keyed by full name
+ // (namespace.name, or the bare name when no namespace is present).
+ namedCache map[string]avro.SchemaNode
+ // bareToFull maps a short name to its sole full name, and ambiguousBare
+ // flags short names defined under more than one namespace so that a
bare
+ // reference to them can be rejected instead of silently picking one.
+ bareToFull map[string]string
+ ambiguousBare map[string]struct{}
+ index int32
}
func newSchemaNode() *schemaNode {
- var schemaCache avro.SchemaCache
- return &schemaNode{name: "", index: -1, schemaCache: &schemaCache}
+ return &schemaNode{
+ index: -1,
+ namedCache: map[string]avro.SchemaNode{},
+ bareToFull: map[string]string{},
+ ambiguousBare: map[string]struct{}{},
+ }
}
func (node *schemaNode) schemaPath() string {
@@ -56,33 +79,112 @@ func (node *schemaNode) schemaPath() string {
return path
}
-func (node *schemaNode) newChild(n string, s avro.Schema) *schemaNode {
+func (node *schemaNode) newChild(n string, s avro.SchemaNode) *schemaNode {
child := &schemaNode{
- name: n,
- parent: node,
- schema: s,
- schemaCache: node.schemaCache,
- index: int32(len(node.childrens)),
- depth: node.depth + 1,
+ name: n,
+ parent: node,
+ node: s,
+ namedCache: node.namedCache,
+ bareToFull: node.bareToFull,
+ ambiguousBare: node.ambiguousBare,
+ index: int32(len(node.childrens)),
}
node.childrens = append(node.childrens, child)
return child
}
func (node *schemaNode) children() []*schemaNode { return node.childrens }
-// func (node *schemaNode) nodeName() string { return node.name }
+// rememberNamed adds a record/enum/fixed SchemaNode to the named-type cache
+// keyed by its full name (namespace.name, or the bare name when no namespace
+// is present), so a later {"type": "ns.Address"} reference resolves back to
the
+// original definition. It also records the short name so an unqualified
+// {"type": "Address"} reference can resolve when unambiguous; if the same
short
+// name is defined under more than one namespace it is flagged as ambiguous and
+// resolveRef will reject bare references to it.
+func (node *schemaNode) rememberNamed(s avro.SchemaNode) {
+ if s.Name == "" {
+ return
+ }
+ full := s.Name
+ if s.Namespace != "" {
+ full = s.Namespace + "." + s.Name
+ }
+ node.namedCache[full] = s
+ if existing, ok := node.bareToFull[s.Name]; ok && existing != full {
+ node.ambiguousBare[s.Name] = struct{}{}
+ } else {
+ node.bareToFull[s.Name] = full
+ }
+}
+
+// resolveRef replaces s with its inline definition if s.Type is a named-type
+// reference rather than a builtin Avro type. atField, when non-empty, names
+// the field this reference appears in and is included in the panic so the
+// user can locate the offending entry. A bare reference whose short name is
+// defined under multiple namespaces is rejected rather than resolved to an
+// arbitrary definition.
+func (node *schemaNode) resolveRef(s avro.SchemaNode, atField string)
avro.SchemaNode {
+ if _, ok := builtinAvroTypes[s.Type]; ok {
+ return s
+ }
+ loc := node.schemaPath()
+ if atField != "" {
+ loc += "." + atField
+ }
+ // An exact full-name match always wins.
+ if def, ok := node.namedCache[s.Type]; ok {
+ return def
+ }
+ // Otherwise treat s.Type as a short name, resolving it only when it is
+ // unambiguous across namespaces.
+ if full, ok := node.bareToFull[s.Type]; ok {
+ if _, ambiguous := node.ambiguousBare[s.Type]; ambiguous {
+ panic(fmt.Errorf("ambiguous named type %q referenced at
%s: defined in multiple namespaces; use a fully-qualified name", s.Type, loc))
+ }
+ return node.namedCache[full]
+ }
+ panic(fmt.Errorf("unknown named type %q referenced at %s", s.Type, loc))
+}
+
+// ArrowSchemaFromAvroJSON parses an Avro schema given as JSON text and returns
+// the equivalent Arrow schema.
+func ArrowSchemaFromAvroJSON(schemaJSON string) (*arrow.Schema, error) {
+ schema, err := avro.Parse(schemaJSON)
+ if err != nil {
+ return nil, err
+ }
+ return arrowSchemaFromAvroInternal(schema)
+}
-// ArrowSchemaFromAvro returns a new Arrow schema from an Avro schema
-func ArrowSchemaFromAvro(schema avro.Schema) (s *arrow.Schema, err error) {
+// ArrowSchemaFromAvro returns a new Arrow schema from a parsed Avro schema.
+//
+// Deprecated: Use [ArrowSchemaFromAvroJSON] instead — it does not couple
+// callers to a particular Avro library through its signature.
+func ArrowSchemaFromAvro(schema hambaAvro.Schema) (*arrow.Schema, error) {
+ js, err := json.Marshal(schema)
+ if err != nil {
+ return nil, fmt.Errorf("%w: could not serialize hamba avro
schema: %w", arrow.ErrInvalid, err)
+ }
+ return ArrowSchemaFromAvroJSON(string(js))
+}
+
+func arrowSchemaFromAvroInternal(schema *avro.Schema) (s *arrow.Schema, err
error) {
defer func() {
if r := recover(); r != nil {
s = nil
err = utils.FormatRecoveredError("invalid avro schema",
r)
}
}()
+ root := schema.Root()
+ // OCF requires the top-level schema to be a record; reject anything
else up
+ // front instead of producing a degenerate or empty Arrow schema. (The
parser
+ // already guarantees a record has a non-empty name.)
+ if root.Type != "record" {
+ panic(fmt.Errorf("avro schema root must be a record, got type
%q", root.Type))
+ }
n := newSchemaNode()
- n.schema = schema
- c := n.newChild(n.schema.(avro.NamedSchema).Name(), n.schema)
+ n.node = root
+ c := n.newChild(root.Name, root)
arrowSchemafromAvro(c)
var fields []arrow.Field
for _, g := range c.children() {
@@ -93,16 +195,16 @@ func ArrowSchemaFromAvro(schema avro.Schema) (s
*arrow.Schema, err error) {
}
func arrowSchemafromAvro(n *schemaNode) {
- if ns, ok := n.schema.(avro.NamedSchema); ok {
- n.schemaCache.Add(ns.Name(), ns)
+ n.node = n.resolveRef(n.node, "")
+ if n.node.Name != "" {
+ n.rememberNamed(n.node)
}
- switch st := n.schema.Type(); st {
+ switch st := n.node.Type; st {
case "record":
iterateFields(n)
case "enum":
- n.schemaCache.Add(n.schema.(avro.NamedSchema).Name(),
n.schema.(*avro.EnumSchema))
symbols := make(map[string]string)
- for index, symbol := range
n.schema.(avro.PropertySchema).(*avro.EnumSchema).Symbols() {
+ for index, symbol := range n.node.Symbols {
k := strconv.FormatInt(int64(index), 10)
symbols[k] = symbol
}
@@ -118,9 +220,12 @@ func arrowSchemafromAvro(n *schemaNode) {
}
n.arrowField = buildArrowField(n, &dt,
arrow.MetadataFrom(symbols))
case "array":
- // logical items type
- c := n.newChild(n.name, n.schema.(*avro.ArraySchema).Items())
- if isLogicalSchemaType(n.schema.(*avro.ArraySchema).Items()) {
+ if n.node.Items == nil {
+ panic(fmt.Errorf("avro array schema at %s has no
'items'", n.schemaPath()))
+ }
+ items := *n.node.Items
+ c := n.newChild(n.name, items)
+ if isLogicalSchemaType(items) {
avroLogicalToArrowField(c)
} else {
arrowSchemafromAvro(c)
@@ -134,62 +239,58 @@ func arrowSchemafromAvro(n *schemaNode) {
}
n.arrowField = buildArrowField(n, typ, c.arrowField.Metadata)
case "map":
-
n.schemaCache.Add(n.schema.(*avro.MapSchema).Values().(avro.NamedSchema).Name(),
n.schema.(*avro.MapSchema).Values())
- c := n.newChild(n.name, n.schema.(*avro.MapSchema).Values())
+ if n.node.Values == nil {
+ panic(fmt.Errorf("avro map schema at %s has no
'values'", n.schemaPath()))
+ }
+ values := *n.node.Values
+ c := n.newChild(n.name, values)
arrowSchemafromAvro(c)
n.arrowField = buildArrowField(n,
arrow.MapOf(arrow.BinaryTypes.String, c.arrowField.Type), c.arrowField.Metadata)
case "union":
- us := n.schema.(*avro.UnionSchema)
- if us.Nullable() {
- if len(us.Types()) > 1 {
- n.schema = us.Types()[1]
- n.union = true
- n.nullable = true
- arrowSchemafromAvro(n)
- }
- } else {
- panic(fmt.Errorf("complex (non-nullable) avro union at
'%v' is not supported", n.schemaPath()))
+ branch, ok := nullableBranch(n.node)
+ if !ok {
+ panic(fmt.Errorf("unsupported avro union at %s: only
['null', T] unions with exactly one non-null branch are supported",
n.schemaPath()))
}
+ n.node = branch
+ n.union = true
+ n.nullable = true
+ arrowSchemafromAvro(n)
// Avro "fixed" field type = Arrow FixedSize Primitive BinaryType
case "fixed":
- n.schemaCache.Add(n.schema.(avro.NamedSchema).Name(),
n.schema.(*avro.FixedSchema))
- if isLogicalSchemaType(n.schema) {
+ if isLogicalSchemaType(n.node) {
avroLogicalToArrowField(n)
} else {
- n.arrowField = buildArrowField(n,
&arrow.FixedSizeBinaryType{ByteWidth: n.schema.(*avro.FixedSchema).Size()},
arrow.Metadata{})
+ n.arrowField = buildArrowField(n,
&arrow.FixedSizeBinaryType{ByteWidth: n.node.Size}, arrow.Metadata{})
}
case "string", "bytes", "int", "long":
- if isLogicalSchemaType(n.schema) {
+ if isLogicalSchemaType(n.node) {
avroLogicalToArrowField(n)
} else {
n.arrowField = buildArrowField(n,
avroPrimitiveToArrowType(string(st)), arrow.Metadata{})
}
case "float", "double", "boolean":
n.arrowField = buildArrowField(n,
avroPrimitiveToArrowType(string(st)), arrow.Metadata{})
- case "<ref>":
- refSchema :=
n.schemaCache.Get(string(n.schema.(*avro.RefSchema).Schema().Name()))
- if refSchema == nil {
- panic(fmt.Errorf("could not find schema for '%v' in
schema cache - %v", n.schemaPath(), n.schema.(*avro.RefSchema).Schema().Name()))
- }
- n.schema = refSchema
- arrowSchemafromAvro(n)
case "null":
-
n.schemaCache.Add(n.schema.(*avro.MapSchema).Values().(avro.NamedSchema).Name(),
&avro.NullSchema{})
n.nullable = true
n.arrowField = buildArrowField(n, arrow.Null, arrow.Metadata{})
+ default:
+ panic(fmt.Errorf("unhandled avro type %q at %s", st,
n.schemaPath()))
}
}
-// iterate record Fields()
+// iterate record Fields
func iterateFields(n *schemaNode) {
- for _, f := range n.schema.(*avro.RecordSchema).Fields() {
- switch ft := f.Type().(type) {
+ for _, f := range n.node.Fields {
+ ft := n.resolveRef(f.Type, f.Name)
+ switch ft.Type {
// Avro "array" field type
- case *avro.ArraySchema:
- n.schemaCache.Add(f.Name(), ft.Items())
- // logical items type
- c := n.newChild(f.Name(), ft.Items())
- if isLogicalSchemaType(ft.Items()) {
+ case "array":
+ if ft.Items == nil {
+ panic(fmt.Errorf("avro array field %s.%s has no
'items'", n.schemaPath(), f.Name))
+ }
+ items := *ft.Items
+ c := n.newChild(f.Name, items)
+ if isLogicalSchemaType(items) {
avroLogicalToArrowField(c)
} else {
arrowSchemafromAvro(c)
@@ -201,11 +302,11 @@ func iterateFields(n *schemaNode) {
c.arrowField = arrow.Field{Name: c.name, Type:
arrow.ListOfNonNullable(c.arrowField.Type), Metadata: c.arrowField.Metadata}
}
// Avro "enum" field type = Arrow dictionary type
- case *avro.EnumSchema:
- n.schemaCache.Add(f.Type().(*avro.EnumSchema).Name(),
f.Type())
- c := n.newChild(f.Name(), f.Type())
+ case "enum":
+ n.rememberNamed(ft)
+ c := n.newChild(f.Name, ft)
symbols := make(map[string]string)
- for index, symbol := range ft.Symbols() {
+ for index, symbol := range ft.Symbols {
k := strconv.FormatInt(int64(index), 10)
symbols[k] = symbol
}
@@ -221,46 +322,43 @@ func iterateFields(n *schemaNode) {
}
c.arrowField = buildArrowField(c, &dt,
arrow.MetadataFrom(symbols))
// Avro "fixed" field type = Arrow FixedSize Primitive
BinaryType
- case *avro.FixedSchema:
- n.schemaCache.Add(f.Name(), f.Type())
- c := n.newChild(f.Name(), f.Type())
- if isLogicalSchemaType(f.Type()) {
+ case "fixed":
+ n.rememberNamed(ft)
+ c := n.newChild(f.Name, ft)
+ if isLogicalSchemaType(ft) {
avroLogicalToArrowField(c)
} else {
arrowSchemafromAvro(c)
}
- case *avro.RecordSchema:
- n.schemaCache.Add(f.Name(), f.Type())
- c := n.newChild(f.Name(), f.Type())
+ case "record":
+ n.rememberNamed(ft)
+ c := n.newChild(f.Name, ft)
iterateFields(c)
- // Avro "map" field type - KVP with value of one type -
keys are strings
- case *avro.MapSchema:
- n.schemaCache.Add(f.Name(), ft.Values())
- c := n.newChild(f.Name(), ft.Values())
+ // Avro "map" field type - KVP with value of one type - keys
are strings
+ case "map":
+ if ft.Values == nil {
+ panic(fmt.Errorf("avro map field %s.%s has no
'values'", n.schemaPath(), f.Name))
+ }
+ values := *ft.Values
+ c := n.newChild(f.Name, values)
arrowSchemafromAvro(c)
c.arrowField = buildArrowField(c,
arrow.MapOf(arrow.BinaryTypes.String, c.arrowField.Type), c.arrowField.Metadata)
- case *avro.UnionSchema:
- if ft.Nullable() {
- if len(ft.Types()) > 1 {
- n.schemaCache.Add(f.Name(),
ft.Types()[1])
- c := n.newChild(f.Name(), ft.Types()[1])
- c.union = true
- c.nullable = true
- arrowSchemafromAvro(c)
- }
- } else {
- panic(fmt.Errorf("complex (non-nullable) avro
union in field '%v' is not supported", f.Name()))
+ case "union":
+ branch, ok := nullableBranch(ft)
+ if !ok {
+ panic(fmt.Errorf("unsupported avro union at
%s.%s: only ['null', T] unions with exactly one non-null branch are supported",
n.schemaPath(), f.Name))
}
+ c := n.newChild(f.Name, branch)
+ c.union = true
+ c.nullable = true
+ arrowSchemafromAvro(c)
default:
- n.schemaCache.Add(f.Name(), f.Type())
- if isLogicalSchemaType(f.Type()) {
- c := n.newChild(f.Name(), f.Type())
+ c := n.newChild(f.Name, ft)
+ if isLogicalSchemaType(ft) {
avroLogicalToArrowField(c)
} else {
- c := n.newChild(f.Name(), f.Type())
arrowSchemafromAvro(c)
}
-
}
}
var fields []arrow.Field
@@ -268,7 +366,7 @@ func iterateFields(n *schemaNode) {
fields = append(fields, child.arrowField)
}
- namedSchema, ok := isNamedSchema(n.schema)
+ namedSchema, ok := isNamedSchema(n.node)
var md arrow.Metadata
if ok && namedSchema != n.name+"_data" && n.union {
@@ -277,22 +375,45 @@ func iterateFields(n *schemaNode) {
n.arrowField = buildArrowField(n, arrow.StructOf(fields...), md)
}
-func isLogicalSchemaType(s avro.Schema) bool {
- lts, ok := s.(avro.LogicalTypeSchema)
- if !ok {
- return false
+// nullableBranch returns the non-null branch of a two-element ["null", T]
+// union, plus true if the union is in that nullable shape. If the union has
+// more than two branches or no null branch, ok is false.
+//
+// Heterogeneous non-nullable unions (e.g. ["null", "int", "string"] or
+// ["int", "string"]) are not supported and callers panic on them rather
+// than silently picking one arm.
+func nullableBranch(s avro.SchemaNode) (avro.SchemaNode, bool) {
+ if s.Type != "union" || len(s.Branches) < 2 {
+ return avro.SchemaNode{}, false
+ }
+ var nonNull *avro.SchemaNode
+ for _, b := range s.Branches {
+ if b.Type == "null" {
+ continue
+ }
+ if nonNull != nil {
+ return avro.SchemaNode{}, false
+ }
+ nonNull = &b
}
- if lts.Logical() != nil {
- return true
+ if nonNull == nil {
+ return avro.SchemaNode{}, false
}
- return false
+ return *nonNull, true
}
-func isNamedSchema(s avro.Schema) (string, bool) {
- if ns, ok := s.(avro.NamedSchema); ok {
- return ns.FullName(), ok
+func isLogicalSchemaType(s avro.SchemaNode) bool {
+ return s.LogicalType != ""
+}
+
+func isNamedSchema(s avro.SchemaNode) (string, bool) {
+ if s.Name == "" {
+ return "", false
+ }
+ if s.Namespace != "" {
+ return s.Namespace + "." + s.Name, true
}
- return "", false
+ return s.Name, true
}
func buildArrowField(n *schemaNode, t arrow.DataType, m arrow.Metadata)
arrow.Field {
@@ -337,7 +458,7 @@ func avroPrimitiveToArrowType(avroFieldType string)
arrow.DataType {
func avroLogicalToArrowField(n *schemaNode) {
var dt arrow.DataType
// Avro logical types
- switch lt := n.schema.(avro.LogicalTypeSchema).Logical(); lt.Type() {
+ switch n.node.LogicalType {
// The decimal logical type represents an arbitrary-precision signed
decimal number of the form unscaled × 10-scale.
// A decimal logical type annotates Avro bytes or fixed types. The byte
array must contain the two’s-complement
// representation of the unscaled integer value in big-endian byte
order. The scale is fixed, and is specified
@@ -348,13 +469,13 @@ func avroLogicalToArrowField(n *schemaNode) {
// precision, a JSON integer representing the (maximum) precision of
decimals stored in this type (required).
case "decimal":
id := arrow.DECIMAL128
- if lt.(*avro.DecimalLogicalSchema).Precision() >
decimal128.MaxPrecision {
+ if n.node.Precision > decimal128.MaxPrecision {
id = arrow.DECIMAL256
}
- dt, _ = arrow.NewDecimalType(id,
int32(lt.(*avro.DecimalLogicalSchema).Precision()),
int32(lt.(*avro.DecimalLogicalSchema).Scale()))
+ dt, _ = arrow.NewDecimalType(id, int32(n.node.Precision),
int32(n.node.Scale))
- // The uuid logical type represents a random generated
universally unique identifier (UUID).
- // A uuid logical type annotates an Avro string. The string has
to conform with RFC-4122
+ // The uuid logical type represents a random generated universally
unique identifier (UUID).
+ // A uuid logical type annotates an Avro string. The string has to
conform with RFC-4122
case "uuid":
dt = extensions.NewUUIDType()
@@ -399,21 +520,19 @@ func avroLogicalToArrowField(n *schemaNode) {
case "timestamp-micros":
dt = arrow.FixedWidthTypes.Timestamp_us
- // The local-timestamp-millis logical type represents a timestamp in a
local timezone, regardless of
- // what specific time zone is considered local, with a precision of one
millisecond.
- // A local-timestamp-millis logical type annotates an Avro long, where
the long stores the number of
- // milliseconds, from 1 January 1970 00:00:00.000.
- // The local (wall-clock) semantics are preserved by leaving TimeZone
unset, distinguishing these from
- // the global timestamp-millis/micros types above which carry a UTC
zone.
+ // The timestamp-nanos logical type represents an instant on the global
timeline with nanosecond
+ // precision. twmb/avro decodes it to time.Time (UTC).
+ case "timestamp-nanos":
+ dt = arrow.FixedWidthTypes.Timestamp_ns
+
+ // The local-timestamp-millis/micros/nanos logical types represent a
timestamp in a local timezone.
+ // Arrow models that as a TimestampType with no time zone set.
case "local-timestamp-millis":
dt = &arrow.TimestampType{Unit: arrow.Millisecond}
-
- // The local-timestamp-micros logical type represents a timestamp in a
local timezone, regardless of
- // what specific time zone is considered local, with a precision of one
microsecond.
- // A local-timestamp-micros logical type annotates an Avro long, where
the long stores the number of
- // microseconds, from 1 January 1970 00:00:00.000000.
case "local-timestamp-micros":
dt = &arrow.TimestampType{Unit: arrow.Microsecond}
+ case "local-timestamp-nanos":
+ dt = &arrow.TimestampType{Unit: arrow.Nanosecond}
// The duration logical type represents an amount of time defined by a
number of months, days and milliseconds.
// This is not equivalent to a number of milliseconds, because,
depending on the moment in time from which the
diff --git a/arrow/avro/schema_test.go b/arrow/avro/schema_test.go
index 689ba3c4..fe5dea36 100644
--- a/arrow/avro/schema_test.go
+++ b/arrow/avro/schema_test.go
@@ -23,7 +23,9 @@ import (
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/avro/testdata"
- avropkg "github.com/hamba/avro/v2"
+ "github.com/apache/arrow-go/v18/arrow/extensions"
+ hambaAvro "github.com/hamba/avro/v2"
+ avro "github.com/twmb/avro"
)
func TestSchemaStringEqual(t *testing.T) {
@@ -128,6 +130,10 @@ func TestSchemaStringEqual(t *testing.T) {
Name: "uuidField",
Type: arrow.BinaryTypes.String,
},
+ {
+ Name: "fixedUuidField",
+ Type: extensions.NewUUIDType(),
+ },
{
Name: "timemillis",
Type: arrow.FixedWidthTypes.Time32ms,
@@ -152,6 +158,14 @@ func TestSchemaStringEqual(t *testing.T) {
Name: "localtimestampmicros",
Type: &arrow.TimestampType{Unit:
arrow.Microsecond},
},
+ {
+ Name: "timestampnanos",
+ Type:
arrow.FixedWidthTypes.Timestamp_ns,
+ },
+ {
+ Name: "localtimestampnanos",
+ Type: &arrow.TimestampType{Unit:
arrow.Nanosecond},
+ },
{
Name: "duration",
Type:
arrow.FixedWidthTypes.MonthDayNanoInterval,
@@ -172,7 +186,7 @@ func TestSchemaStringEqual(t *testing.T) {
if err != nil {
t.Fatalf("%v", err)
}
- got, err := ArrowSchemaFromAvro(schema)
+ got, err := ArrowSchemaFromAvroJSON(schema)
if err != nil {
t.Fatalf("%v", err)
}
@@ -185,25 +199,115 @@ func TestSchemaStringEqual(t *testing.T) {
}
}
-func TestComplexUnionReportsError(t *testing.T) {
- // Non-nullable union (e.g. [int, string]) is not supported and should
- // produce a clear error rather than being silently dropped.
- const avroSchemaJSON = `{
+// Remove together with [ArrowSchemaFromAvro] at the next major release.
+func TestArrowSchemaFromAvro_Deprecated_PreservesLogicalTypesOnFixed(t
*testing.T) {
+ const schemaJSON = `{
"type": "record",
- "name": "WithComplexUnion",
+ "name": "Sample",
"fields": [
- {"name": "value", "type": ["int", "string"]}
+ {"name": "id", "type": "int"},
+ {"name": "name", "type": "string"},
+ {"name": "nullable_double", "type": ["null", "double"]},
+ {"name": "uuid_string", "type": {"type": "string",
"logicalType": "uuid"}},
+ {"name": "ts_millis", "type": {"type": "long",
"logicalType": "timestamp-millis"}},
+ {"name": "fixed_uuid", "type": {"type": "fixed",
"name": "FUUID", "size": 16, "logicalType": "uuid"}},
+ {"name": "fixed_decimal", "type": {"type": "fixed",
"name": "FDec", "size": 16, "logicalType": "decimal", "precision": 20, "scale":
4}},
+ {"name": "fixed_duration", "type": {"type": "fixed",
"name": "FDur", "size": 12, "logicalType": "duration"}}
]
}`
- schema, err := avropkg.Parse(avroSchemaJSON)
+ hambaSchema, err := hambaAvro.Parse(schemaJSON)
if err != nil {
- t.Fatalf("avro parse: %v", err)
+ t.Fatalf("hamba parse: %v", err)
}
- got, err := ArrowSchemaFromAvro(schema)
- if err == nil {
- t.Fatalf("expected error for complex union, got schema=%v", got)
+
+ got, err := ArrowSchemaFromAvro(hambaSchema)
+ if err != nil {
+ t.Fatalf("ArrowSchemaFromAvro: %v", err)
}
- if !strings.Contains(err.Error(), "union") {
- t.Fatalf("expected error to mention union, got: %v", err)
+ want, err := ArrowSchemaFromAvroJSON(schemaJSON)
+ if err != nil {
+ t.Fatalf("ArrowSchemaFromAvroJSON: %v", err)
}
+ if got.String() != want.String() {
+ t.Fatalf("schema mismatch:\n got = %s\nwant = %s",
got.String(), want.String())
+ }
+}
+
+// OCF requires a record at the top level. Non-record roots are rejected by our
+// own guard, while a record with an empty name is rejected earlier by the avro
+// parser; both must surface an error rather than produce a degenerate schema.
+func TestArrowSchemaFromAvroJSON_RejectsInvalidRoot(t *testing.T) {
+ tests := []struct {
+ name string
+ schemaJSON string
+ wantErr string // caught by our guard
+ wantParse bool // caught by the avro parser before our guard
+ }{
+ {name: "string root", schemaJSON: `"string"`, wantErr: "must be
a record"},
+ {name: "array root", schemaJSON:
`{"type":"array","items":"int"}`, wantErr: "must be a record"},
+ {name: "empty record name", schemaJSON:
`{"type":"record","name":"","fields":[{"name":"x","type":"int"}]}`, wantParse:
true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := ArrowSchemaFromAvroJSON(tt.schemaJSON)
+ if err == nil {
+ t.Fatalf("expected error for %s", tt.schemaJSON)
+ }
+ if tt.wantParse {
+ // The parser rejects it before our guard runs,
so the error is
+ // not wrapped as an invalid avro schema by
arrowSchemaFromAvroInternal.
+ if strings.Contains(err.Error(), "must be a
record") {
+ t.Fatalf("expected parser error, got
our guard's error: %v", err)
+ }
+ return
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("unexpected error for %s: %v",
tt.schemaJSON, err)
+ }
+ })
+ }
+}
+
+// A named record referenced again by name resolves back to the same
definition.
+func TestArrowSchemaFromAvroJSON_ReusedNamedReference(t *testing.T) {
+ const schemaJSON = `{"type":"record","name":"Root","fields":[
+
{"name":"a","type":{"type":"record","name":"Foo","fields":[{"name":"x","type":"int"}]}},
+ {"name":"b","type":"Foo"}]}`
+ s, err := ArrowSchemaFromAvroJSON(schemaJSON)
+ if err != nil {
+ t.Fatalf("ArrowSchemaFromAvroJSON: %v", err)
+ }
+ if s.NumFields() != 2 {
+ t.Fatalf("got %d fields, want 2", s.NumFields())
+ }
+ if a, b := s.Field(0).Type.String(), s.Field(1).Type.String(); a != b {
+ t.Fatalf("reused reference resolved to a different type:\n a =
%s\n b = %s", a, b)
+ }
+}
+
+// Two records sharing a short name across namespaces are kept distinct by full
+// name, and an unqualified reference to that short name is rejected instead of
+// silently resolving to one of them (restoring hamba's erroring behavior).
+func TestResolveRefAmbiguousBareName(t *testing.T) {
+ n := newSchemaNode()
+ n.rememberNamed(avro.SchemaNode{Name: "Foo", Namespace: "a"})
+ n.rememberNamed(avro.SchemaNode{Name: "Foo", Namespace: "b"})
+
+ got := n.resolveRef(avro.SchemaNode{Type: "a.Foo"}, "")
+ if got.Namespace != "a" {
+ t.Fatalf("a.Foo resolved to namespace %q, want a",
got.Namespace)
+ }
+
+ func() {
+ defer func() {
+ r := recover()
+ if r == nil {
+ t.Fatalf("expected panic for ambiguous bare
reference")
+ }
+ if !strings.Contains(fmt.Sprint(r), "ambiguous named
type") {
+ t.Fatalf("unexpected panic: %v", r)
+ }
+ }()
+ n.resolveRef(avro.SchemaNode{Type: "Foo"}, "field")
+ }()
}
diff --git a/arrow/avro/testdata/alltypes.avsc
b/arrow/avro/testdata/alltypes.avsc
index 27db841d..c1e3c596 100644
--- a/arrow/avro/testdata/alltypes.avsc
+++ b/arrow/avro/testdata/alltypes.avsc
@@ -164,6 +164,15 @@
"name": "uuidField",
"type": "string"
},
+ {
+ "name": "fixedUuidField",
+ "type": {
+ "type": "fixed",
+ "name": "FixedUUID",
+ "size": 16,
+ "logicalType": "uuid"
+ }
+ },
{
"name": "timemillis",
"type": {
@@ -206,6 +215,20 @@
"logicalType": "local-timestamp-micros"
}
},
+ {
+ "name": "timestampnanos",
+ "type": {
+ "type": "long",
+ "logicalType": "timestamp-nanos"
+ }
+ },
+ {
+ "name": "localtimestampnanos",
+ "type": {
+ "type": "long",
+ "logicalType": "local-timestamp-nanos"
+ }
+ },
{
"name": "duration",
"type": {
diff --git a/arrow/avro/testdata/testdata.go b/arrow/avro/testdata/testdata.go
index 4c8bac71..9e5564b5 100644
--- a/arrow/avro/testdata/testdata.go
+++ b/arrow/avro/testdata/testdata.go
@@ -17,8 +17,6 @@
package testdata
import (
- "encoding/base64"
- "encoding/binary"
"encoding/json"
"fmt"
"log"
@@ -28,8 +26,9 @@ import (
"strings"
"time"
- avro "github.com/hamba/avro/v2"
- "github.com/hamba/avro/v2/ocf"
+ "github.com/google/uuid"
+ "github.com/twmb/avro"
+ "github.com/twmb/avro/ocf"
)
const (
@@ -42,107 +41,181 @@ const (
type ByteArray []byte
func (b ByteArray) MarshalJSON() ([]byte, error) {
- return json.Marshal(base64.StdEncoding.EncodeToString(b))
+ return json.Marshal([]byte(b))
}
-type TimestampMicros int64
+type TimestampJSON time.Time
-func (t TimestampMicros) MarshalJSON() ([]byte, error) {
- ts := time.Unix(0,
int64(t)*int64(time.Microsecond)).UTC().Format(time.RFC3339Nano)
- return json.Marshal(ts)
+func (t TimestampJSON) MarshalJSON() ([]byte, error) {
+ return json.Marshal(time.Time(t).UTC().Format(time.RFC3339Nano))
}
-type TimestampMillis int64
+type TimeMillisJSON time.Duration
-func (t TimestampMillis) MarshalJSON() ([]byte, error) {
- ts := time.Unix(0,
int64(t)*int64(time.Millisecond)).UTC().Format(time.RFC3339Nano)
- return json.Marshal(ts)
-}
-
-type TimeMillis time.Duration
-
-func (t TimeMillis) MarshalJSON() ([]byte, error) {
+func (t TimeMillisJSON) MarshalJSON() ([]byte, error) {
ts := time.Unix(0, int64(t)).UTC().Format("15:04:05.000")
return json.Marshal(strings.TrimRight(ts, "0."))
}
-type TimeMicros time.Duration
+type TimeMicrosJSON time.Duration
-func (t TimeMicros) MarshalJSON() ([]byte, error) {
+func (t TimeMicrosJSON) MarshalJSON() ([]byte, error) {
ts := time.Unix(0, int64(t)).UTC().Format("15:04:05.000000")
return json.Marshal(strings.TrimRight(ts, "0."))
}
-type ExplicitNamespace [12]byte
+type FixedJSON []byte
-func (t ExplicitNamespace) MarshalJSON() ([]byte, error) {
- return json.Marshal(t[:])
+func (t FixedJSON) MarshalJSON() ([]byte, error) {
+ return json.Marshal([]byte(t))
}
-type MD5 [16]byte
+type FixedUUIDJSON [16]byte
-func (t MD5) MarshalJSON() ([]byte, error) {
- return json.Marshal(t[:])
+func (t FixedUUIDJSON) MarshalJSON() ([]byte, error) {
+ return json.Marshal(uuid.UUID(t).String())
}
-type DecimalType []byte
+type DecimalJSON struct {
+ Rat *big.Rat
+}
-func (t DecimalType) MarshalJSON() ([]byte, error) {
- v := new(big.Int).SetBytes(t)
- s := fmt.Sprintf("%0*s", decimalTypeScale+1, v.String())
+func (t DecimalJSON) MarshalJSON() ([]byte, error) {
+ num := new(big.Int).Set(t.Rat.Num())
+ den := new(big.Int).Set(t.Rat.Denom())
+ scaleFactor := new(big.Int).Exp(big.NewInt(10),
big.NewInt(decimalTypeScale), nil)
+ num.Mul(num, scaleFactor)
+ num.Quo(num, den)
+ s := fmt.Sprintf("%0*s", decimalTypeScale+1, num.String())
point := len(s) - decimalTypeScale
return json.Marshal(s[:point] + "." + s[point:])
}
-type Duration [12]byte
-
-func (t Duration) MarshalJSON() ([]byte, error) {
- milliseconds := int32(binary.LittleEndian.Uint32(t[8:12]))
+type DurationJSON avro.Duration
- m := map[string]interface{}{
- "months": int32(binary.LittleEndian.Uint32(t[0:4])),
- "days": int32(binary.LittleEndian.Uint32(t[4:8])),
- "nanoseconds": int64(milliseconds) * int64(time.Millisecond),
+func (t DurationJSON) MarshalJSON() ([]byte, error) {
+ m := map[string]any{
+ "months": int32(t.Months),
+ "days": int32(t.Days),
+ "nanoseconds": int64(t.Milliseconds) * int64(time.Millisecond),
}
return json.Marshal(m)
}
-type Date int32
+type DateJSON time.Time
-func (t Date) MarshalJSON() ([]byte, error) {
- v := time.Unix(int64(t)*86400, 0).UTC().Format("2006-01-02")
- return json.Marshal(v)
+func (t DateJSON) MarshalJSON() ([]byte, error) {
+ return json.Marshal(time.Time(t).UTC().Format("2006-01-02"))
}
type Example struct {
- InheritNull string `avro:"inheritNull"
json:"inheritNull"`
- ExplicitNamespace ExplicitNamespace `avro:"explicitNamespace"
json:"explicitNamespace"`
- FullName FullNameData `avro:"fullName" json:"fullName"`
- ID int32 `avro:"id" json:"id"`
- BigID int64 `avro:"bigId" json:"bigId"`
- Temperature *float32 `avro:"temperature"
json:"temperature"`
- Fraction *float64 `avro:"fraction" json:"fraction"`
- IsEmergency bool `avro:"is_emergency"
json:"is_emergency"`
- RemoteIP *ByteArray `avro:"remote_ip" json:"remote_ip"`
- NullableRemoteIPS *[]ByteArray `avro:"nullable_remote_ips"
json:"nullable_remote_ips"`
- Person PersonData `avro:"person" json:"person"`
- DecimalField DecimalType `avro:"decimalField"
json:"decimalField"`
- Decimal256Field DecimalType `avro:"decimal256Field"
json:"decimal256Field"`
- UUIDField string `avro:"uuidField" json:"uuidField"`
- TimeMillis TimeMillis `avro:"timemillis"
json:"timemillis"`
- TimeMicros TimeMicros `avro:"timemicros"
json:"timemicros"`
- TimestampMillis TimestampMillis `avro:"timestampmillis"
json:"timestampmillis"`
- TimestampMicros TimestampMicros `avro:"timestampmicros"
json:"timestampmicros"`
- LocalTSMillis TimestampMillis `avro:"localtimestampmillis"
json:"localtimestampmillis"`
- LocalTSMicros TimestampMicros `avro:"localtimestampmicros"
json:"localtimestampmicros"`
- Duration Duration `avro:"duration" json:"duration"`
- Date Date `avro:"date" json:"date"`
+ InheritNull string `avro:"inheritNull"`
+ ExplicitNamespace [12]byte `avro:"explicitNamespace"`
+ FullName FullNameData `avro:"fullName"`
+ ID int32 `avro:"id"`
+ BigID int64 `avro:"bigId"`
+ Temperature *float32 `avro:"temperature"`
+ Fraction *float64 `avro:"fraction"`
+ IsEmergency bool `avro:"is_emergency"`
+ RemoteIP *[]byte `avro:"remote_ip"`
+ NullableRemoteIPS *[][]byte `avro:"nullable_remote_ips"`
+ Person PersonData `avro:"person"`
+ DecimalField *big.Rat `avro:"decimalField"`
+ Decimal256Field *big.Rat `avro:"decimal256Field"`
+ UUIDField string `avro:"uuidField"`
+ FixedUUIDField [16]byte `avro:"fixedUuidField"`
+ TimeMillis time.Duration `avro:"timemillis"`
+ TimeMicros time.Duration `avro:"timemicros"`
+ TimestampMillis time.Time `avro:"timestampmillis"`
+ TimestampMicros time.Time `avro:"timestampmicros"`
+ LocalTSMillis time.Time `avro:"localtimestampmillis"`
+ LocalTSMicros time.Time `avro:"localtimestampmicros"`
+ TimestampNanos time.Time `avro:"timestampnanos"`
+ LocalTSNanos time.Time `avro:"localtimestampnanos"`
+ Duration avro.Duration `avro:"duration"`
+ Date time.Time `avro:"date"`
+}
+
+func (e Example) MarshalJSON() ([]byte, error) {
+ var remoteIP *ByteArray
+ if e.RemoteIP != nil {
+ v := ByteArray(*e.RemoteIP)
+ remoteIP = &v
+ }
+ var nullableRemoteIPs *[]ByteArray
+ if e.NullableRemoteIPS != nil {
+ arr := make([]ByteArray, len(*e.NullableRemoteIPS))
+ for i, b := range *e.NullableRemoteIPS {
+ arr[i] = ByteArray(b)
+ }
+ nullableRemoteIPs = &arr
+ }
+ out := struct {
+ InheritNull string `json:"inheritNull"`
+ ExplicitNamespace FixedJSON `json:"explicitNamespace"`
+ FullName fullNameJSON `json:"fullName"`
+ ID int32 `json:"id"`
+ BigID int64 `json:"bigId"`
+ Temperature *float32 `json:"temperature"`
+ Fraction *float64 `json:"fraction"`
+ IsEmergency bool `json:"is_emergency"`
+ RemoteIP *ByteArray `json:"remote_ip"`
+ NullableRemoteIPS *[]ByteArray `json:"nullable_remote_ips"`
+ Person PersonData `json:"person"`
+ DecimalField DecimalJSON `json:"decimalField"`
+ Decimal256Field DecimalJSON `json:"decimal256Field"`
+ UUIDField string `json:"uuidField"`
+ FixedUUIDField FixedUUIDJSON `json:"fixedUuidField"`
+ TimeMillis TimeMillisJSON `json:"timemillis"`
+ TimeMicros TimeMicrosJSON `json:"timemicros"`
+ TimestampMillis TimestampJSON `json:"timestampmillis"`
+ TimestampMicros TimestampJSON `json:"timestampmicros"`
+ LocalTSMillis TimestampJSON `json:"localtimestampmillis"`
+ LocalTSMicros TimestampJSON `json:"localtimestampmicros"`
+ TimestampNanos TimestampJSON `json:"timestampnanos"`
+ LocalTSNanos TimestampJSON `json:"localtimestampnanos"`
+ Duration DurationJSON `json:"duration"`
+ Date DateJSON `json:"date"`
+ }{
+ InheritNull: e.InheritNull,
+ ExplicitNamespace: FixedJSON(e.ExplicitNamespace[:]),
+ FullName: fullNameJSON{InheritNamespace:
e.FullName.InheritNamespace, Md5: FixedJSON(e.FullName.Md5[:])},
+ ID: e.ID,
+ BigID: e.BigID,
+ Temperature: e.Temperature,
+ Fraction: e.Fraction,
+ IsEmergency: e.IsEmergency,
+ RemoteIP: remoteIP,
+ NullableRemoteIPS: nullableRemoteIPs,
+ Person: e.Person,
+ DecimalField: DecimalJSON{Rat: e.DecimalField},
+ Decimal256Field: DecimalJSON{Rat: e.Decimal256Field},
+ UUIDField: e.UUIDField,
+ FixedUUIDField: FixedUUIDJSON(e.FixedUUIDField),
+ TimeMillis: TimeMillisJSON(e.TimeMillis),
+ TimeMicros: TimeMicrosJSON(e.TimeMicros),
+ TimestampMillis: TimestampJSON(e.TimestampMillis),
+ TimestampMicros: TimestampJSON(e.TimestampMicros),
+ LocalTSMillis: TimestampJSON(e.LocalTSMillis),
+ LocalTSMicros: TimestampJSON(e.LocalTSMicros),
+ TimestampNanos: TimestampJSON(e.TimestampNanos),
+ LocalTSNanos: TimestampJSON(e.LocalTSNanos),
+ Duration: DurationJSON(e.Duration),
+ Date: DateJSON(e.Date),
+ }
+ return json.Marshal(out)
}
type FullNameData struct {
- InheritNamespace string `avro:"inheritNamespace"
json:"inheritNamespace"`
- Md5 MD5 `avro:"md5" json:"md5"`
+ InheritNamespace string `avro:"inheritNamespace"`
+ Md5 [16]byte `avro:"md5"`
}
+
+type fullNameJSON struct {
+ InheritNamespace string `json:"inheritNamespace"`
+ Md5 FixedJSON `json:"md5"`
+}
+
type MapField map[string]int64
func (t MapField) MarshalJSON() ([]byte, error) {
@@ -199,29 +272,44 @@ func TestdataDir() string {
return ""
}
-func AllTypesAvroSchema() (avro.Schema, error) {
+// AllTypesAvroSchema returns the raw JSON of the bundled `alltypes.avsc`
+// testdata schema.
+func AllTypesAvroSchema() (string, error) {
sp := filepath.Join(TestdataDir(), SchemaFileName)
avroSchemaBytes, err := os.ReadFile(sp)
if err != nil {
- return nil, err
+ return "", err
}
- return avro.ParseBytes(avroSchemaBytes)
+ return string(avroSchemaBytes), nil
}
func sampleData() Example {
+ now := time.Now().UTC()
+ // Truncate per unit so each timestamp logical type round-trips exactly;
+ // nanos needs no truncation.
+ tsMillis := now.Truncate(time.Millisecond)
+ tsMicros := now.Truncate(time.Microsecond)
+ date := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0,
time.UTC)
+
+ decimal := new(big.Rat).SetFrac(big.NewInt(9876), big.NewInt(100)) //
98.76
+ decimal256, ok :=
new(big.Rat).SetString("12345678901234567890123456789012345678901234567890123456.78")
+ if !ok {
+ log.Fatal("bad decimal256 literal in sampleData")
+ }
+
return Example{
InheritNull: "a",
- ExplicitNamespace: ExplicitNamespace{1, 2, 3, 4, 5, 6, 7, 8, 9,
10, 11, 12},
+ ExplicitNamespace: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
12},
FullName: FullNameData{
InheritNamespace: "d",
- Md5: MD5{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15},
+ Md5: [16]byte{0, 1, 2, 3, 4, 5, 6, 7, 8,
9, 10, 11, 12, 13, 14, 15},
},
ID: 42,
BigID: 42000000000,
Temperature: func() *float32 { v := float32(36.6); return &v
}(),
Fraction: func() *float64 { v := float64(0.75); return &v
}(),
IsEmergency: true,
- RemoteIP: func() *ByteArray { v := ByteArray{192, 168, 1,
1}; return &v }(),
+ RemoteIP: func() *[]byte { v := []byte{192, 168, 1, 1};
return &v }(),
Person: PersonData{
Lastname: "Doe",
Address: AddressUSRecord{
@@ -231,21 +319,20 @@ func sampleData() Example {
Mapfield: MapField{"foo": 123},
ArrayField: []string{"one", "two"},
},
- DecimalField: DecimalType{0x00, 0x00, 0x00, 0x00, 0x00, 0x26,
0x94},
- Decimal256Field: DecimalType{
- 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
- 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
- 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01,
- },
+ DecimalField: decimal,
+ Decimal256Field: decimal256,
UUIDField: "123e4567-e89b-12d3-a456-426614174000",
- TimeMillis: TimeMillis(50412345 * time.Millisecond),
- TimeMicros: TimeMicros(50412345678 * time.Microsecond),
- TimestampMillis: TimestampMillis(time.Now().UnixNano() /
int64(time.Millisecond)),
- TimestampMicros: TimestampMicros(time.Now().UnixNano() /
int64(time.Microsecond)),
- LocalTSMillis: TimestampMillis(time.Now().UnixNano() /
int64(time.Millisecond)),
- LocalTSMicros: TimestampMicros(time.Now().UnixNano() /
int64(time.Microsecond)),
- Duration: Duration{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
12},
- Date: Date(time.Now().Unix() / 86400),
+ FixedUUIDField: [16]byte{0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b,
0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66, 0x55, 0x44, 0x00, 0x00},
+ TimeMillis: 50412345 * time.Millisecond,
+ TimeMicros: 50412345678 * time.Microsecond,
+ TimestampMillis: tsMillis,
+ TimestampMicros: tsMicros,
+ LocalTSMillis: tsMillis,
+ LocalTSMicros: tsMicros,
+ TimestampNanos: now,
+ LocalTSNanos: now,
+ Duration: avro.Duration{Months: 1, Days: 2,
Milliseconds: 3},
+ Date: date,
}
}
@@ -256,11 +343,16 @@ func writeOCFSampleData(td string, data Example) string {
log.Fatal(err)
}
defer ocfFile.Close()
- schema, err := AllTypesAvroSchema()
+ schemaJSON, err := AllTypesAvroSchema()
+ if err != nil {
+ log.Fatal(err)
+ }
+ schema, err := avro.Parse(schemaJSON)
if err != nil {
log.Fatal(err)
}
- encoder, err := ocf.NewEncoder(schema.String(), ocfFile)
+ // Pass the original JSON so logical-type annotations survive in the
OCF header.
+ encoder, err := ocf.NewWriter(ocfFile, schema,
ocf.WithSchema(schemaJSON))
if err != nil {
log.Fatal(err)
}
diff --git a/go.mod b/go.mod
index 58d01ea1..24c06edd 100644
--- a/go.mod
+++ b/go.mod
@@ -43,6 +43,7 @@ require (
github.com/substrait-io/substrait-go/v8 v8.1.1
github.com/substrait-io/substrait-protobuf/go v0.85.0
github.com/tidwall/sjson v1.2.5
+ github.com/twmb/avro v1.5.0
github.com/zeebo/xxh3 v1.1.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/sync v0.22.0
@@ -67,7 +68,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
- github.com/golang/snappy v1.0.0 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/asmfmt v1.3.2 // indirect
diff --git a/go.sum b/go.sum
index 72098753..cc9d792d 100644
--- a/go.sum
+++ b/go.sum
@@ -54,8 +54,6 @@ github.com/goccy/go-yaml v1.17.1
h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod
h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4
h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod
h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
-github.com/golang/snappy v1.0.0/go.mod
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.12.19+incompatible
h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod
h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -164,6 +162,8 @@ github.com/tidwall/pretty v1.2.0
h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod
h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod
h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/twmb/avro v1.5.0 h1:9jmbvVQQBcyWHv/6zS+q5+nmASiR8/GwhKF/sU7u71c=
+github.com/twmb/avro v1.5.0/go.mod
h1:X0fT1dY2xcbV4YuCE4mYro+qljHl4kUF5uA/2z1rgSk=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod
h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod
h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=