diff --git a/cmd/metal-api/internal/datastore/ip.go b/cmd/metal-api/internal/datastore/ip.go index 5c54649a..d89ad7d9 100644 --- a/cmd/metal-api/internal/datastore/ip.go +++ b/cmd/metal-api/internal/datastore/ip.go @@ -23,9 +23,7 @@ type IPSearchQuery struct { } // GenerateTerm generates the project search query term. -func (p *IPSearchQuery) generateTerm(rs *RethinkStore) *r.Term { - q := *rs.ipTable() - +func (p *IPSearchQuery) Query(q r.Term) *r.Term { if p.IPAddress != nil { q = q.Filter(func(row r.Term) r.Term { return row.Field("id").Eq(*p.IPAddress) @@ -94,7 +92,7 @@ func (rs *RethinkStore) FindIPByID(id string) (*metal.IP, error) { // SearchIPs returns the result of the ips search request query. func (rs *RethinkStore) SearchIPs(q *IPSearchQuery, ips *metal.IPs) error { - return rs.searchEntities(q.generateTerm(rs), ips) + return rs.searchEntities(q.Query(*rs.ipTable()), ips) } // ListIPs returns all ips. diff --git a/cmd/metal-api/internal/datastore/ip_test.go b/cmd/metal-api/internal/datastore/ip_test.go index 1808abab..06ae0a9c 100644 --- a/cmd/metal-api/internal/datastore/ip_test.go +++ b/cmd/metal-api/internal/datastore/ip_test.go @@ -1,34 +1,38 @@ package datastore import ( + "context" + "log/slog" "testing" "github.com/google/go-cmp/cmp" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/generic-datastore" "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" "github.com/metal-stack/metal-api/cmd/metal-api/internal/testdata" + "github.com/metal-stack/metal-lib/pkg/pointer" ) func TestRethinkStore_FindIPByID(t *testing.T) { ds, mock := InitMockDB(t) testdata.InitMockDBData(mock) - + is := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).IP() tests := []struct { name string - rs *RethinkStore + is generic.Storage[*metal.IP] id string want *metal.IP wantErr bool }{ { name: "TestRethinkStore_FindIP Test 1", - rs: ds, + is: is, id: "1.2.3.4", want: &testdata.IP1, wantErr: false, }, { name: "TestRethinkStore_FindIP Test 2", - rs: ds, + is: is, id: "2.3.4.5", want: &testdata.IP2, wantErr: false, @@ -37,7 +41,42 @@ func TestRethinkStore_FindIPByID(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - got, err := tt.rs.FindIPByID(tt.id) + got, err := tt.is.Get(context.Background(), tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("RethinkStore.FindIP() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("RethinkStore.FindIP() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// FIXME needs proper mock to work +func TestRethinkStore_QueryIP(t *testing.T) { + ds, mock := InitMockDB(t) + testdata.InitMockDBData(mock) + is := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).IP() + tests := []struct { + name string + is generic.Storage[*metal.IP] + query generic.EntityQuery + want *metal.IP + wantErr bool + }{ + { + name: "TestRethinkStore_FindIP Test 1", + is: is, + query: &IPSearchQuery{IPAddress: pointer.Pointer("1.2.3.4")}, + want: &testdata.IP1, + wantErr: false, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + got, err := tt.is.Find(context.Background(), tt.query) if (err != nil) != tt.wantErr { t.Errorf("RethinkStore.FindIP() error = %v, wantErr %v", err, tt.wantErr) return @@ -52,16 +91,17 @@ func TestRethinkStore_FindIPByID(t *testing.T) { func TestRethinkStore_ListIPs(t *testing.T) { ds, mock := InitMockDB(t) testdata.InitMockDBData(mock) + is := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).IP() tests := []struct { name string - rs *RethinkStore + is generic.Storage[*metal.IP] want metal.IPs wantErr bool }{ { name: "TestRethinkStore_ListIPs Test 1", - rs: ds, + is: is, want: testdata.TestIPs, wantErr: false, }, @@ -69,7 +109,7 @@ func TestRethinkStore_ListIPs(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - got, err := tt.rs.ListIPs() + got, err := tt.is.List(context.Background()) if (err != nil) != tt.wantErr { t.Errorf("RethinkStore.ListIPs() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/cmd/metal-api/internal/datastore/partition_test.go b/cmd/metal-api/internal/datastore/partition_test.go index 2d57fcdb..9885a43a 100644 --- a/cmd/metal-api/internal/datastore/partition_test.go +++ b/cmd/metal-api/internal/datastore/partition_test.go @@ -1,34 +1,39 @@ package datastore import ( + "context" + "log/slog" "testing" "github.com/google/go-cmp/cmp" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/generic-datastore" "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" "github.com/metal-stack/metal-api/cmd/metal-api/internal/testdata" ) func TestRethinkStore_FindPartition(t *testing.T) { ds, mock := InitMockDB(t) + + ps := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).Partition() testdata.InitMockDBData(mock) tests := []struct { name string - rs *RethinkStore + ps generic.Storage[*metal.Partition] id string want *metal.Partition wantErr bool }{ { name: "Test 1", - rs: ds, + ps: ps, id: "1", want: &testdata.Partition1, wantErr: false, }, { name: "Test 2", - rs: ds, + ps: ps, id: "2", want: &testdata.Partition2, wantErr: false, @@ -37,7 +42,7 @@ func TestRethinkStore_FindPartition(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - got, err := tt.rs.FindPartition(tt.id) + got, err := tt.ps.Get(context.Background(), tt.id) if (err != nil) != tt.wantErr { t.Errorf("RethinkStore.FindPartition() error = %v, wantErr %v", err, tt.wantErr) return @@ -52,16 +57,17 @@ func TestRethinkStore_FindPartition(t *testing.T) { func TestRethinkStore_ListPartitions(t *testing.T) { ds, mock := InitMockDB(t) testdata.InitMockDBData(mock) + ps := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).Partition() tests := []struct { name string - rs *RethinkStore + ps generic.Storage[*metal.Partition] want metal.Partitions wantErr bool }{ { name: "Test 1", - rs: ds, + ps: ps, want: testdata.TestPartitions, wantErr: false, }, @@ -69,7 +75,7 @@ func TestRethinkStore_ListPartitions(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - got, err := tt.rs.ListPartitions() + got, err := tt.ps.List(context.Background()) if (err != nil) != tt.wantErr { t.Errorf("RethinkStore.ListPartitions() error = %v, wantErr %v", err, tt.wantErr) return @@ -84,16 +90,17 @@ func TestRethinkStore_ListPartitions(t *testing.T) { func TestRethinkStore_CreatePartition(t *testing.T) { ds, mock := InitMockDB(t) testdata.InitMockDBData(mock) + ps := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).Partition() tests := []struct { name string - rs *RethinkStore + ps generic.Storage[*metal.Partition] p *metal.Partition wantErr bool }{ { name: "Test 1", - rs: ds, + ps: ps, p: &testdata.Partition1, wantErr: false, }, @@ -101,7 +108,7 @@ func TestRethinkStore_CreatePartition(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - if err := tt.rs.CreatePartition(tt.p); (err != nil) != tt.wantErr { + if err := tt.ps.Create(context.Background(), tt.p); (err != nil) != tt.wantErr { t.Errorf("RethinkStore.CreatePartition() error = %v, wantErr %v", err, tt.wantErr) } }) @@ -111,22 +118,23 @@ func TestRethinkStore_CreatePartition(t *testing.T) { func TestRethinkStore_DeletePartition(t *testing.T) { ds, mock := InitMockDB(t) testdata.InitMockDBData(mock) + ps := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).Partition() tests := []struct { name string - rs *RethinkStore + ps generic.Storage[*metal.Partition] p *metal.Partition wantErr bool }{ { name: "Test 1", - rs: ds, + ps: ps, p: &testdata.Partition1, wantErr: false, }, { name: "Test 2", - rs: ds, + ps: ps, p: &testdata.Partition2, wantErr: false, }, @@ -134,7 +142,7 @@ func TestRethinkStore_DeletePartition(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - err := tt.rs.DeletePartition(tt.p) + err := tt.ps.Delete(context.Background(), tt.p) if (err != nil) != tt.wantErr { t.Errorf("RethinkStore.DeletePartition() error = %v, wantErr %v", err, tt.wantErr) return @@ -146,24 +154,25 @@ func TestRethinkStore_DeletePartition(t *testing.T) { func TestRethinkStore_UpdatePartition(t *testing.T) { ds, mock := InitMockDB(t) testdata.InitMockDBData(mock) + ps := generic.New(slog.Default(), ds.DBName(), ds.QueryExecutor()).Partition() tests := []struct { name string - rs *RethinkStore + ps generic.Storage[*metal.Partition] oldPartition *metal.Partition newPartition *metal.Partition wantErr bool }{ { name: "Test 1", - rs: ds, + ps: ps, oldPartition: &testdata.Partition1, newPartition: &testdata.Partition2, wantErr: false, }, { name: "Test 2", - rs: ds, + ps: ps, oldPartition: &testdata.Partition2, newPartition: &testdata.Partition1, wantErr: false, @@ -172,7 +181,7 @@ func TestRethinkStore_UpdatePartition(t *testing.T) { for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - if err := tt.rs.UpdatePartition(tt.oldPartition, tt.newPartition); (err != nil) != tt.wantErr { + if err := tt.ps.Update(context.Background(), tt.oldPartition, tt.newPartition); (err != nil) != tt.wantErr { t.Errorf("RethinkStore.UpdatePartition() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/cmd/metal-api/internal/datastore/rethinkdb.go b/cmd/metal-api/internal/datastore/rethinkdb.go index 38cbc07c..2f2cf56e 100644 --- a/cmd/metal-api/internal/datastore/rethinkdb.go +++ b/cmd/metal-api/internal/datastore/rethinkdb.go @@ -350,6 +350,14 @@ tryAgain: return s } +func (rs *RethinkStore) DBName() string { + return rs.dbname +} + +func (rs *RethinkStore) QueryExecutor() r.QueryExecutor { + return rs.session +} + func (rs *RethinkStore) findEntityByID(table *r.Term, entity interface{}, id string) error { res, err := table.Get(id).Run(rs.session) if err != nil { diff --git a/cmd/metal-api/internal/generic-datastore/rethinkdb.go b/cmd/metal-api/internal/generic-datastore/rethinkdb.go new file mode 100644 index 00000000..9413e50d --- /dev/null +++ b/cmd/metal-api/internal/generic-datastore/rethinkdb.go @@ -0,0 +1,279 @@ +package generic + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + r "gopkg.in/rethinkdb/rethinkdb-go.v6" +) + +const entityAlreadyModifiedErrorMessage = "the entity was changed from another, please retry" + +type ( + // Entity is an interface that allows metal entities to be created and stored + // into the database with the generic creation and update functions. + // + // see https://go.googlesource.com/proposal/+/HEAD/design/43651-type-parameters.md#pointer-method-example for possible solution to prevent slices of pointers. + Entity interface { + // GetID returns the entity's id + GetID() string + // SetID sets the entity's id + SetID(id string) + // GetChanged returns the entity's changed time + GetChanged() time.Time + // SetChanged sets the entity's changed time + SetChanged(changed time.Time) + // GetCreated sets the entity's creation time + GetCreated() time.Time + // SetCreated sets the entity's creation time + SetCreated(created time.Time) + } + + EntityQuery interface { + Query(q r.Term) *r.Term + } + + Storage[E Entity] interface { + Create(ctx context.Context, e E) error + Update(ctx context.Context, new, old E) error + Upsert(ctx context.Context, e E) error + Delete(ctx context.Context, e E) error + Get(ctx context.Context, id string) (E, error) + Find(ctx context.Context, query EntityQuery) (E, error) + Search(ctx context.Context, query EntityQuery) ([]E, error) + List(ctx context.Context) ([]E, error) + } + + Datastore struct { + event Storage[*metal.ProvisioningEventContainer] + filesystemlayout Storage[*metal.FilesystemLayout] + image Storage[*metal.Image] + ip Storage[*metal.IP] + machine Storage[*metal.Machine] + network Storage[*metal.Network] + partition Storage[*metal.Partition] + size Storage[*metal.Size] + sizeimageConstraint Storage[*metal.SizeImageConstraint] + sw Storage[*metal.Switch] + switchStatus Storage[*metal.SwitchStatus] + } + + rethinkStore[E Entity] struct { + log *slog.Logger + queryExecutor r.QueryExecutor + dbname string + table r.Term + tableName string + } +) + +func New(log *slog.Logger, dbname string, queryExecutor r.QueryExecutor) *Datastore { + return &Datastore{ + event: newStorage[*metal.ProvisioningEventContainer](log, dbname, "event", queryExecutor), + filesystemlayout: newStorage[*metal.FilesystemLayout](log, dbname, "filesystemlayout", queryExecutor), + image: newStorage[*metal.Image](log, dbname, "image", queryExecutor), + ip: newStorage[*metal.IP](log, dbname, "ip", queryExecutor), + machine: newStorage[*metal.Machine](log, dbname, "machine", queryExecutor), + network: newStorage[*metal.Network](log, dbname, "network", queryExecutor), + partition: newStorage[*metal.Partition](log, dbname, "partition", queryExecutor), + size: newStorage[*metal.Size](log, dbname, "size", queryExecutor), + sizeimageConstraint: newStorage[*metal.SizeImageConstraint](log, dbname, "sizeimageconstraint", queryExecutor), + sw: newStorage[*metal.Switch](log, dbname, "switch", queryExecutor), + switchStatus: newStorage[*metal.SwitchStatus](log, dbname, "switchstatus", queryExecutor), + } +} + +func (d *Datastore) Event() Storage[*metal.ProvisioningEventContainer] { + return d.event +} + +func (d *Datastore) FilesystemLayout() Storage[*metal.FilesystemLayout] { + return d.filesystemlayout +} +func (d *Datastore) Image() Storage[*metal.Image] { + return d.image +} +func (d *Datastore) IP() Storage[*metal.IP] { + return d.ip +} +func (d *Datastore) Machine() Storage[*metal.Machine] { + return d.machine +} +func (d *Datastore) Network() Storage[*metal.Network] { + return d.network +} +func (d *Datastore) Partition() Storage[*metal.Partition] { + return d.partition +} +func (d *Datastore) Size() Storage[*metal.Size] { + return d.size +} +func (d *Datastore) SizeImageConstraint() Storage[*metal.SizeImageConstraint] { + return d.sizeimageConstraint +} +func (d *Datastore) Switch() Storage[*metal.Switch] { + return d.sw +} +func (d *Datastore) SwitchStatus() Storage[*metal.SwitchStatus] { + return d.switchStatus +} + +// newStorage creates a new Storage which uses the given database abstraction. +func newStorage[E Entity](log *slog.Logger, dbname, tableName string, queryExecutor r.QueryExecutor) Storage[E] { + ds := &rethinkStore[E]{ + log: log, + queryExecutor: queryExecutor, + dbname: dbname, + table: r.DB(dbname).Table(tableName), + tableName: tableName, + } + return ds +} + +// Create implements Storage. +func (rs *rethinkStore[E]) Create(ctx context.Context, e E) error { + now := time.Now() + e.SetCreated(now) + e.SetChanged(now) + + res, err := rs.table.Insert(e).RunWrite(rs.queryExecutor, r.RunOpts{Context: ctx}) + if err != nil { + if r.IsConflictErr(err) { + return metal.Conflict("cannot create %v in database, entity already exists: %s", rs.tableName, e.GetID()) + } + return fmt.Errorf("cannot create %v in database: %w", rs.tableName, err) + } + + if e.GetID() == "" && len(res.GeneratedKeys) > 0 { + e.SetID(res.GeneratedKeys[0]) + } + + return nil +} + +// Delete implements Storage. +func (rs *rethinkStore[E]) Delete(ctx context.Context, e E) error { + _, err := rs.table.Get(e.GetID()).Delete().RunWrite(rs.queryExecutor, r.RunOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("cannot delete %v with id %q from database: %w", rs.tableName, e.GetID(), err) + } + return nil +} + +// Find implements Storage. +func (rs *rethinkStore[E]) Find(ctx context.Context, query EntityQuery) (E, error) { + var zero E + res, err := query.Query(rs.table).Run(rs.queryExecutor, r.RunOpts{Context: ctx}) + if err != nil { + return zero, fmt.Errorf("cannot find %v in database: %w", rs.tableName, err) + } + defer res.Close() + if res.IsNil() { + return zero, metal.NotFound("no %v with found", rs.tableName) + } + + e := new(E) + hasResult := res.Next(e) + if !hasResult { + return zero, fmt.Errorf("cannot find %v in database: %w", rs.tableName, err) + } + + next := new(E) + hasResult = res.Next(&next) + if hasResult { + return zero, fmt.Errorf("more than one %v exists", rs.tableName) + } + + return *e, nil +} + +func (rs *rethinkStore[E]) Search(ctx context.Context, query EntityQuery) ([]E, error) { + res, err := query.Query(rs.table).Run(rs.queryExecutor, r.RunOpts{Context: ctx}) + if err != nil { + return nil, fmt.Errorf("cannot search %v in database: %w", rs.tableName, err) + } + defer res.Close() + + result := new([]E) + err = res.All(result) + if err != nil { + return nil, fmt.Errorf("cannot fetch all entities: %w", err) + } + return *result, nil +} + +func (rs *rethinkStore[E]) List(ctx context.Context) ([]E, error) { + res, err := rs.table.Run(rs.queryExecutor, r.RunOpts{Context: ctx}) + if err != nil { + return nil, fmt.Errorf("cannot list %v from database: %w", rs.tableName, err) + } + defer res.Close() + + result := new([]E) + err = res.All(result) + if err != nil { + return nil, fmt.Errorf("cannot fetch all entities: %w", err) + } + return *result, nil +} + +// Get implements Storage. +func (rs *rethinkStore[E]) Get(ctx context.Context, id string) (E, error) { + var zero E + res, err := rs.table.Get(id).Run(rs.queryExecutor, r.RunOpts{Context: ctx}) + if err != nil { + return zero, fmt.Errorf("cannot find %v with id %q in database: %w", rs.tableName, id, err) + } + defer res.Close() + if res.IsNil() { + return zero, metal.NotFound("no %v with id %q found", rs.tableName, id) + } + e := new(E) + err = res.One(e) + if err != nil { + return zero, fmt.Errorf("more than one %v with same id exists: %w", rs.tableName, err) + } + return *e, nil +} + +// Update implements Storage. +func (rs *rethinkStore[E]) Update(ctx context.Context, new, old E) error { + new.SetChanged(time.Now()) + + // FIXME use context + _, err := rs.table.Get(old.GetID()).Replace(func(row r.Term) r.Term { + return r.Branch(row.Field("changed").Eq(r.Expr(old.GetChanged())), new, r.Error(entityAlreadyModifiedErrorMessage)) + }).RunWrite(rs.queryExecutor) + if err != nil { + if strings.Contains(err.Error(), entityAlreadyModifiedErrorMessage) { + return metal.Conflict("cannot update %v (%s): %s", rs.tableName, old.GetID(), entityAlreadyModifiedErrorMessage) + } + return fmt.Errorf("cannot update %v (%s): %w", rs.tableName, old.GetID(), err) + } + + return nil +} + +func (rs *rethinkStore[E]) Upsert(ctx context.Context, e E) error { + now := time.Now() + if e.GetCreated().IsZero() { + e.SetCreated(now) + } + e.SetChanged(now) + + res, err := rs.table.Insert(e, r.InsertOpts{ + Conflict: "replace", + }).RunWrite(rs.queryExecutor) + if err != nil { + return fmt.Errorf("cannot upsert %v (%s) in database: %w", rs.tableName, e.GetID(), err) + } + + if e.GetID() == "" && len(res.GeneratedKeys) > 0 { + e.SetID(res.GeneratedKeys[0]) + } + return nil +} diff --git a/cmd/metal-api/internal/service/ip-service_test.go b/cmd/metal-api/internal/service/ip-service_test.go index c473ba5f..2d15e2dd 100644 --- a/cmd/metal-api/internal/service/ip-service_test.go +++ b/cmd/metal-api/internal/service/ip-service_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/metal-stack/metal-lib/bus" + "github.com/metal-stack/metal-lib/pkg/pointer" "github.com/metal-stack/metal-lib/pkg/tag" mdmv1 "github.com/metal-stack/masterdata-api/api/v1" @@ -85,6 +86,40 @@ func TestGetIP(t *testing.T) { require.Equal(t, testdata.IP1.Name, *result.Name) } +func TestFindIPs(t *testing.T) { + ds, mock := datastore.InitMockDB(t) + testdata.InitMockDBData(mock) + + logger := slog.Default() + ipservice, err := NewIP(logger, ds, bus.DirectEndpoints(), ipam.New(goipam.New()), nil) + require.NoError(t, err) + container := restful.NewContainer().Add(ipservice) + + js, err := json.Marshal(v1.IPFindRequest{IPSearchQuery: datastore.IPSearchQuery{ + IPAddress: pointer.Pointer("1.2.3.4"), + }}) + require.NoError(t, err) + body := bytes.NewBuffer(js) + + req := httptest.NewRequest("POST", "/v1/ip/find", body) + req.Header.Add("Content-Type", "application/json") + container = injectViewer(logger, container, req) + w := httptest.NewRecorder() + container.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, w.Body.String()) + var result []v1.IPResponse + err = json.NewDecoder(resp.Body).Decode(&result) + + require.NoError(t, err) + require.Len(t, result, 1) + found := result[0] + require.Equal(t, testdata.IP1.IPAddress, found.IPAddress) + require.Equal(t, testdata.IP1.Name, *found.Name) +} + func TestGetIPNotFound(t *testing.T) { ds, mock := datastore.InitMockDB(t) testdata.InitMockDBData(mock) diff --git a/cmd/metal-api/internal/testdata/testdata.go b/cmd/metal-api/internal/testdata/testdata.go index 273f5bfb..9384356a 100644 --- a/cmd/metal-api/internal/testdata/testdata.go +++ b/cmd/metal-api/internal/testdata/testdata.go @@ -835,6 +835,7 @@ func InitMockDBData(mock *r.Mock) { mock.On(r.DB("mockdb").Table("network").Filter(func(var_3 r.Term) r.Term { return var_3.Field("partitionid").Eq("1") }).Filter(func(var_4 r.Term) r.Term { return var_4.Field("privatesuper").Eq(true) })).Return(Nw3, nil) mock.On(r.DB("mockdb").Table("ip").Get("1.2.3.4")).Return(IP1, nil) + mock.On(r.DB("mockdb").Table("ip").Filter(func(q r.Term) r.Term { return q.Field("id").Eq("1.2.3.4") })).Return(IP1, nil) mock.On(r.DB("mockdb").Table("ip").Get("2.3.4.5")).Return(IP2, nil) mock.On(r.DB("mockdb").Table("ip").Get("3.4.5.6")).Return(IP3, nil) mock.On(r.DB("mockdb").Table("ip").Get("8.8.8.8")).Return(nil, errors.New("Test Error"))