From 6a3e726da2cdfe62f85e6f185a1d16a731dd8b6f Mon Sep 17 00:00:00 2001 From: Kilian Boute Date: Thu, 13 Aug 2026 13:44:12 +0000 Subject: [PATCH] feat(database): add find cursor iteration Expose optional per-document iteration and decode support on find results so consumers can handle individual malformed documents without aborting the complete result set, with matching mock behavior and tests. --- pkg/database/database.go | 10 +++++++ pkg/database/mock.go | 48 +++++++++++++++++++++++++++++++-- pkg/database/mock_test.go | 56 +++++++++++++++++++++++++++++++++++++++ pkg/database/mongodb.go | 29 ++++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/pkg/database/database.go b/pkg/database/database.go index e414e7d..91091da 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -21,6 +21,16 @@ type FindResultInterface interface { Err() error } +// CursorResultInterface is the optional per-document iteration capability for +// find results. Consumers use it when individual decode failures must be +// handled without aborting the complete result set. +type CursorResultInterface interface { + FindResultInterface + Next(ctx context.Context) bool + Decode(dest any) error + Close(ctx context.Context) error +} + // UpdateResultInterface defines the interface for update operation results type UpdateResultInterface interface { MatchedCount() int64 diff --git a/pkg/database/mock.go b/pkg/database/mock.go index 6c788c5..4c419d7 100644 --- a/pkg/database/mock.go +++ b/pkg/database/mock.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "reflect" "time" "go.mongodb.org/mongo-driver/bson" @@ -86,10 +87,15 @@ type MockDeleteResult struct { // MockFindResult implements FindResultInterface for testing type MockFindResult struct { - results any - err error + results any + err error + index int + current any + currentSet bool } +var _ CursorResultInterface = (*MockFindResult)(nil) + // All decodes all results into dest func (m *MockFindResult) All(dest any) error { if m.err != nil { @@ -101,6 +107,44 @@ func (m *MockFindResult) All(dest any) error { return copySliceResult(m.results, dest) } +// Next advances to the next configured mock result. +func (m *MockFindResult) Next(context.Context) bool { + if m.err != nil || m.results == nil { + return false + } + + results := reflect.ValueOf(m.results) + if results.Kind() != reflect.Slice && results.Kind() != reflect.Array { + return false + } + if m.index >= results.Len() { + m.current = nil + m.currentSet = false + return false + } + + m.current = results.Index(m.index).Interface() + m.currentSet = true + m.index++ + return true +} + +// Decode decodes the current configured mock result. +func (m *MockFindResult) Decode(dest any) error { + if m.err != nil { + return m.err + } + if !m.currentSet { + return fmt.Errorf("find cursor has no current document") + } + return copyResult(m.current, dest) +} + +// Close closes the mock cursor. +func (m *MockFindResult) Close(context.Context) error { + return m.err +} + // Err returns any error func (m *MockFindResult) Err() error { return m.err diff --git a/pkg/database/mock_test.go b/pkg/database/mock_test.go index c20906f..39f1070 100644 --- a/pkg/database/mock_test.go +++ b/pkg/database/mock_test.go @@ -89,6 +89,62 @@ func TestMockDatabase(t *testing.T) { } }) + t.Run("CursorIterationSkipsIndividualDecodeError", func(t *testing.T) { + mock := NewMockDatabase().QueueFind([]bson.M{ + {"name": "first"}, + {"name": []string{"invalid"}}, + {"name": "third"}, + }, nil) + + result := mock.Find(context.Background(), "testdb", "users", bson.M{}) + cursor, ok := result.(CursorResultInterface) + if !ok { + t.Fatal("mock find result must support cursor iteration") + } + + var names []string + decodeErrors := 0 + for cursor.Next(context.Background()) { + var document struct { + Name string `bson:"name"` + } + if err := cursor.Decode(&document); err != nil { + decodeErrors++ + continue + } + names = append(names, document.Name) + } + if err := cursor.Err(); err != nil { + t.Fatalf("cursor error: %v", err) + } + if err := cursor.Close(context.Background()); err != nil { + t.Fatalf("close cursor: %v", err) + } + if decodeErrors != 1 || len(names) != 2 || names[0] != "first" || names[1] != "third" { + t.Fatalf("decode errors = %d, names = %v", decodeErrors, names) + } + }) + + t.Run("CursorIterationPreservesFindError", func(t *testing.T) { + expectedErr := errors.New("find failed") + result := NewMockDatabase().QueueFind(nil, expectedErr). + Find(context.Background(), "testdb", "users", bson.M{}) + cursor := result.(CursorResultInterface) + + if cursor.Next(context.Background()) { + t.Fatal("failed find must not have a next document") + } + if !errors.Is(cursor.Err(), expectedErr) { + t.Fatalf("cursor error = %v, want %v", cursor.Err(), expectedErr) + } + if !errors.Is(cursor.Decode(&bson.M{}), expectedErr) { + t.Fatalf("decode error = %v, want %v", cursor.Decode(&bson.M{}), expectedErr) + } + if !errors.Is(cursor.Close(context.Background()), expectedErr) { + t.Fatalf("close error = %v, want %v", cursor.Close(context.Background()), expectedErr) + } + }) + t.Run("ExpectFindOneWithResult", func(t *testing.T) { mock := NewMockDatabase() expectedUser := map[string]any{ diff --git a/pkg/database/mongodb.go b/pkg/database/mongodb.go index 5bf0f04..d2881bd 100644 --- a/pkg/database/mongodb.go +++ b/pkg/database/mongodb.go @@ -382,6 +382,8 @@ type FindResult struct { err error } +var _ CursorResultInterface = (*FindResult)(nil) + // All decodes all results into the provided destination slice. // The dest parameter must be a pointer to a slice. func (fr *FindResult) All(dest any) error { @@ -395,8 +397,35 @@ func (fr *FindResult) All(dest any) error { return fr.cursor.All(fr.ctx, dest) } +// Next advances to the next document in the result set. +func (fr *FindResult) Next(ctx context.Context) bool { + return fr.err == nil && fr.cursor != nil && fr.cursor.Next(ctx) +} + +// Decode decodes the current document selected by Next. +func (fr *FindResult) Decode(dest any) error { + if fr.err != nil { + return fr.err + } + if fr.cursor == nil { + return fmt.Errorf("find cursor unavailable") + } + return fr.cursor.Decode(dest) +} + +// Close closes the underlying MongoDB cursor. +func (fr *FindResult) Close(ctx context.Context) error { + if fr.cursor == nil { + return fr.err + } + return fr.cursor.Close(ctx) +} + // Err returns any error that occurred during the query. func (fr *FindResult) Err() error { + if fr.err == nil && fr.cursor != nil { + return fr.cursor.Err() + } return fr.err }