diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index f710d91f022..e07f7b64a1b 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,6 +25,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima [[release-3-7-7]] === TinkerPop 3.7.7 (Release Date: NOT OFFICIALLY RELEASED YET) +* Added `NextN(n)` to `Traversal` in `gremlin-go` for batched result iteration, providing API parity with `next(n)` in the Java, Python, and .NET GLVs. * Fixed conjoin has incorrect null handling. * Expanded `gremlin-python` CI matrix to test against Python 3.9, 3.10, 3.11, 3.12, and 3.13. * Add Node 26 support for `gremlin-javascript` and `gremlint`. diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index 4d9c3f606c3..b25fd397411 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -485,7 +485,11 @@ fmt.Println(resAnon.GetString()) === Differences All step names start with a capital letter which is consistent with the idiomatic style for Go. This use of Pascal-case -extends to enums like `Direction`, e.g. `Direction.OUT` is `Direction.Out` in Go. +extends to enums like `Direction`, e.g. `Direction.OUT` is `Direction.Out` in Go. + +Because Go does not support method overloading, the batched form of `next(n)` available in other GLVs is exposed as a +distinct method `NextN(n)` on `Traversal`. `Next()` returns a single `*Result`, while `NextN(n)` returns up to `n` +results as `[]*Result`. [[gremlin-go-aliases]] === Aliases diff --git a/gremlin-go/driver/traversal.go b/gremlin-go/driver/traversal.go index b5e17e93c97..1a62c057de5 100644 --- a/gremlin-go/driver/traversal.go +++ b/gremlin-go/driver/traversal.go @@ -117,6 +117,34 @@ func (t *Traversal) Next() (*Result, error) { return result, err } +// NextN returns up to n results from the traversal. If the traversal has +// fewer than n results, only those results are returned. If n is non-positive, +// an empty slice is returned. +func (t *Traversal) NextN(n int) ([]*Result, error) { + if n <= 0 { + return []*Result{}, nil + } + results, err := t.GetResultSet() + if err != nil { + return nil, err + } + out := make([]*Result, 0, n) + for i := 0; i < n; i++ { + if results.IsEmpty() { + break + } + result, ok, err := results.One() + if err != nil { + return out, err + } + if !ok { + break + } + out = append(out, result) + } + return out, results.GetError() +} + // GetResultSet submits the traversal and returns the ResultSet. func (t *Traversal) GetResultSet() (ResultSet, error) { if t.results == nil { diff --git a/gremlin-go/driver/traversal_test.go b/gremlin-go/driver/traversal_test.go index dab1cfaa0b3..475617e7674 100644 --- a/gremlin-go/driver/traversal_test.go +++ b/gremlin-go/driver/traversal_test.go @@ -23,6 +23,7 @@ import ( "crypto/tls" "reflect" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -527,6 +528,132 @@ func TestTraversal(t *testing.T) { }) } +func TestTraversalNextN(t *testing.T) { + // makeClosedResultSet builds a channelResultSet that is already closed + // after the given results have been pushed onto the channel directly + // (i.e. without going through addResult, so no bulk unrolling). + makeClosedResultSet := func(results ...*Result) *channelResultSet { + rs := newChannelResultSetCapacity("test", &synchronizedMap{make(map[string]ResultSet), sync.Mutex{}}, len(results)+1).(*channelResultSet) + for _, r := range results { + rs.channel <- r + } + rs.channelMutex.Lock() + rs.closed = true + close(rs.channel) + rs.channelMutex.Unlock() + return rs + } + + t.Run("returns exactly n when n is less than available", func(t *testing.T) { + rs := makeClosedResultSet(&Result{"a"}, &Result{"b"}, &Result{"c"}, &Result{"d"}) + trav := &Traversal{results: rs} + + got, err := trav.NextN(3) + assert.Nil(t, err) + assert.Equal(t, 3, len(got)) + assert.Equal(t, "a", got[0].Data) + assert.Equal(t, "b", got[1].Data) + assert.Equal(t, "c", got[2].Data) + }) + + t.Run("returns exactly n when n equals available", func(t *testing.T) { + rs := makeClosedResultSet(&Result{"a"}, &Result{"b"}) + trav := &Traversal{results: rs} + + got, err := trav.NextN(2) + assert.Nil(t, err) + assert.Equal(t, 2, len(got)) + }) + + t.Run("returns all available when n exceeds available", func(t *testing.T) { + rs := makeClosedResultSet(&Result{"a"}, &Result{"b"}) + trav := &Traversal{results: rs} + + got, err := trav.NextN(5) + assert.Nil(t, err) + assert.Equal(t, 2, len(got)) + assert.Equal(t, "a", got[0].Data) + assert.Equal(t, "b", got[1].Data) + }) + + t.Run("returns empty slice when n is zero", func(t *testing.T) { + rs := makeClosedResultSet(&Result{"a"}) + trav := &Traversal{results: rs} + + got, err := trav.NextN(0) + assert.Nil(t, err) + assert.NotNil(t, got) + assert.Equal(t, 0, len(got)) + }) + + t.Run("returns empty slice when n is negative", func(t *testing.T) { + rs := makeClosedResultSet(&Result{"a"}) + trav := &Traversal{results: rs} + + got, err := trav.NextN(-3) + assert.Nil(t, err) + assert.NotNil(t, got) + assert.Equal(t, 0, len(got)) + }) + + t.Run("returns empty slice when traversal is exhausted", func(t *testing.T) { + rs := makeClosedResultSet() + trav := &Traversal{results: rs} + + got, err := trav.NextN(3) + assert.Nil(t, err) + assert.Equal(t, 0, len(got)) + }) + + t.Run("unrolls bulked Traverser across the batch", func(t *testing.T) { + // addResult unrolls bulks when the incoming Result wraps a slice of *Traverser. + rs := newChannelResultSetCapacity("test-bulk", &synchronizedMap{make(map[string]ResultSet), sync.Mutex{}}, 8).(*channelResultSet) + rs.addResult(&Result{[]interface{}{&Traverser{bulk: 3, value: "x"}}}) + rs.channelMutex.Lock() + rs.closed = true + close(rs.channel) + rs.channelMutex.Unlock() + trav := &Traversal{results: rs} + + got, err := trav.NextN(2) + assert.Nil(t, err) + assert.Equal(t, 2, len(got)) + assert.Equal(t, "x", got[0].Data) + assert.Equal(t, "x", got[1].Data) + }) + + t.Run("can be called repeatedly to drain in batches", func(t *testing.T) { + rs := makeClosedResultSet(&Result{1}, &Result{2}, &Result{3}, &Result{4}, &Result{5}) + trav := &Traversal{results: rs} + + first, err := trav.NextN(2) + assert.Nil(t, err) + assert.Equal(t, 2, len(first)) + + second, err := trav.NextN(10) + assert.Nil(t, err) + assert.Equal(t, 3, len(second)) + + third, err := trav.NextN(1) + assert.Nil(t, err) + assert.Equal(t, 0, len(third)) + }) + + t.Run("propagates error from ResultSet", func(t *testing.T) { + rs := newChannelResultSetCapacity("test-err", &synchronizedMap{make(map[string]ResultSet), sync.Mutex{}}, 1).(*channelResultSet) + rs.setError(assert.AnError) + rs.channelMutex.Lock() + rs.closed = true + close(rs.channel) + rs.channelMutex.Unlock() + trav := &Traversal{results: rs} + + got, err := trav.NextN(5) + assert.Equal(t, assert.AnError, err) + assert.Equal(t, 0, len(got)) + }) +} + func newWithOptionsConnection(t *testing.T) *GraphTraversalSource { // No authentication integration test with graphs loaded and alias configured server testNoAuthWithAliasUrl := getEnvOrDefaultString("GREMLIN_SERVER_URL", noAuthUrl)