rename locations to places (#38)

* rename locations to places

* update failing things

Co-authored-by: Janos Dobronszki <dobronszki@gmail.com>
This commit is contained in:
Asim Aslam
2021-01-13 13:08:19 +00:00
committed by GitHub
parent a8e56e5e9e
commit ef2f313e33
15 changed files with 662 additions and 882 deletions

177
places/handler/places.go Normal file
View File

@@ -0,0 +1,177 @@
package handler
import (
"context"
"sync"
"time"
"github.com/google/uuid"
geo "github.com/hailocab/go-geoindex"
"google.golang.org/protobuf/types/known/timestamppb"
"google.golang.org/protobuf/types/known/wrapperspb"
"gorm.io/gorm"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
"github.com/micro/services/places/model"
pb "github.com/micro/services/places/proto"
)
var (
ErrMissingPlaces = errors.BadRequest("MISSING_LOCATIONS", "One or more places are required")
ErrMissingLatitude = errors.BadRequest("MISSING_LATITUDE", "Latitude is required")
ErrMissingLongitude = errors.BadRequest("MISSING_LONGITUDE", "Longitude is required")
ErrMissingID = errors.BadRequest("MISSING_ID", "Place ID is required")
ErrMissingIDs = errors.BadRequest("MISSING_IDS", "One or more Place IDs are required")
ErrMissingBefore = errors.BadRequest("MISSING_BEFORE", "Before timestamp is required")
ErrMissingAfter = errors.BadRequest("MISSING_AFTER", "After timestamp is required")
ErrMissingRadius = errors.BadRequest("MISSING_RADIUS", "Radius is required")
)
type Places struct {
sync.RWMutex
Geoindex *geo.PointsIndex
DB *gorm.DB
}
// Save a set of places
func (l *Places) Save(ctx context.Context, req *pb.SaveRequest, rsp *pb.SaveResponse) error {
// validate the request
if len(req.Places) == 0 {
return ErrMissingPlaces
}
for _, l := range req.Places {
if l.Latitude == nil {
return ErrMissingLatitude
}
if l.Longitude == nil {
return ErrMissingLongitude
}
if len(l.Id) == 0 {
return ErrMissingID
}
}
// construct the database objects
ls := make([]*model.Location, len(req.Places))
for i, lc := range req.Places {
loc := &model.Location{
ID: uuid.New().String(),
PlaceID: lc.Id,
Latitude: lc.Latitude.Value,
Longitude: lc.Longitude.Value,
}
if lc.Timestamp != nil {
loc.Timestamp = lc.Timestamp.AsTime()
} else {
loc.Timestamp = time.Now()
}
ls[i] = loc
}
// write to the database
if err := l.DB.Create(ls).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error writing to the database")
}
// write to the geoindex
l.Lock()
defer l.Unlock()
for _, lc := range ls {
l.Geoindex.Add(lc)
}
return nil
}
// Last places for a set of users
func (l *Places) Last(ctx context.Context, req *pb.LastRequest, rsp *pb.ListResponse) error {
// validate the request
if req.Ids == nil {
return ErrMissingIDs
}
// query the database
q := l.DB.Raw("SELECT DISTINCT ON (place_id) place_id, timestamp, latitude, longitude FROM places WHERE place_id IN (?) ORDER BY place_id, timestamp DESC", req.Ids)
var locs []*model.Location
if err := q.Find(&locs).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error reading from the database")
}
// serialize the result
rsp.Places = serializePlaces(locs)
return nil
}
// Near returns the places near a point
func (l *Places) Near(ctx context.Context, req *pb.NearRequest, rsp *pb.ListResponse) error {
// validate the request
if req.Latitude == nil {
return ErrMissingLatitude
}
if req.Longitude == nil {
return ErrMissingLongitude
}
if req.Radius == nil {
return ErrMissingRadius
}
// query the geoindex
l.RLock()
p := geo.NewGeoPoint("query", req.Latitude.Value, req.Longitude.Value)
result := l.Geoindex.PointsWithin(p, geo.Km(req.Radius.Value), func(p geo.Point) bool {
return true
})
l.RUnlock()
// serialize the result
locs := make([]*model.Location, len(result))
for i, r := range result {
locs[i] = r.(*model.Location)
}
rsp.Places = serializePlaces(locs)
return nil
}
// Read places for a group of users between two points in time
func (l *Places) Read(ctx context.Context, req *pb.ReadRequest, rsp *pb.ListResponse) error {
// validate the request
if len(req.Ids) == 0 {
return ErrMissingIDs
}
if req.Before == nil {
return ErrMissingBefore
}
if req.After == nil {
return ErrMissingAfter
}
// construct the request
q := l.DB.Model(&model.Location{})
q = q.Order("timestamp ASC")
q = q.Where("place_id IN (?) AND timestamp > ? AND timestamp < ?", req.Ids, req.After.AsTime(), req.Before.AsTime())
var locs []*model.Location
if err := q.Find(&locs).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error reading from the database")
}
// serialize the result
rsp.Places = serializePlaces(locs)
return nil
}
func serializePlaces(locs []*model.Location) []*pb.Location {
rsp := make([]*pb.Location, len(locs))
for i, l := range locs {
rsp[i] = &pb.Location{
Id: l.PlaceID,
Latitude: &wrapperspb.DoubleValue{Value: l.Latitude},
Longitude: &wrapperspb.DoubleValue{Value: l.Longitude},
Timestamp: timestamppb.New(l.Timestamp),
}
}
return rsp
}

View File

@@ -0,0 +1,519 @@
package handler_test
import (
"context"
"sort"
"testing"
"time"
"github.com/google/uuid"
geo "github.com/hailocab/go-geoindex"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
"google.golang.org/protobuf/types/known/wrapperspb"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/micro/services/places/handler"
"github.com/micro/services/places/model"
pb "github.com/micro/services/places/proto"
)
func testHandler(t *testing.T) pb.PlacesHandler {
// connect to the database
db, err := gorm.Open(postgres.Open("postgresql://postgres@localhost:5432/places?sslmode=disable"), &gorm.Config{})
if err != nil {
t.Fatalf("Error connecting to database: %v", err)
}
// migrate the database
if err := db.AutoMigrate(&model.Location{}); err != nil {
t.Fatalf("Error migrating database: %v", err)
}
// clean any data from a previous run
if err := db.Exec("TRUNCATE TABLE places CASCADE").Error; err != nil {
t.Fatalf("Error cleaning database: %v", err)
}
return &handler.Places{DB: db, Geoindex: geo.NewPointsIndex(geo.Km(0.1))}
}
func TestSave(t *testing.T) {
tt := []struct {
Name string
Places []*pb.Location
Error error
}{
{
Name: "NoPlaces",
Error: handler.ErrMissingPlaces,
},
{
Name: "NoLatitude",
Places: []*pb.Location{
{
Longitude: &wrapperspb.DoubleValue{Value: -0.1246},
Id: uuid.New().String(),
},
},
Error: handler.ErrMissingLatitude,
},
{
Name: "NoLongitude",
Places: []*pb.Location{
{
Latitude: &wrapperspb.DoubleValue{Value: -0.1246},
Id: uuid.New().String(),
},
},
Error: handler.ErrMissingLongitude,
},
{
Name: "OneLocation",
Places: []*pb.Location{
{
Latitude: &wrapperspb.DoubleValue{Value: 51.5007},
Longitude: &wrapperspb.DoubleValue{Value: 0.1246},
Timestamp: timestamppb.New(time.Now()),
Id: uuid.New().String(),
},
},
},
{
Name: "ManyPlaces",
Places: []*pb.Location{
{
Latitude: &wrapperspb.DoubleValue{Value: 51.5007},
Longitude: &wrapperspb.DoubleValue{Value: 0.1246},
Timestamp: timestamppb.New(time.Now()),
Id: uuid.New().String(),
},
{
Latitude: &wrapperspb.DoubleValue{Value: 51.003},
Longitude: &wrapperspb.DoubleValue{Value: -0.1246},
Id: uuid.New().String(),
},
},
},
}
h := testHandler(t)
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
err := h.Save(context.Background(), &pb.SaveRequest{
Places: tc.Places,
}, &pb.SaveResponse{})
assert.Equal(t, tc.Error, err)
})
}
}
func TestLast(t *testing.T) {
h := testHandler(t)
t.Run("MissingIDs", func(t *testing.T) {
err := h.Last(context.Background(), &pb.LastRequest{}, &pb.ListResponse{})
assert.Equal(t, handler.ErrMissingIDs, err)
})
t.Run("NoMatches", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Last(context.Background(), &pb.LastRequest{
Ids: []string{uuid.New().String()},
}, &rsp)
assert.NoError(t, err)
assert.Empty(t, rsp.Places)
})
// generate some example data to work with
loc1 := &pb.Location{
Latitude: &wrapperspb.DoubleValue{Value: 51.5007},
Longitude: &wrapperspb.DoubleValue{Value: 0.1246},
Timestamp: timestamppb.New(time.Now()),
Id: "a",
}
loc2 := &pb.Location{
Latitude: &wrapperspb.DoubleValue{Value: 51.6007},
Longitude: &wrapperspb.DoubleValue{Value: 0.1546},
Timestamp: timestamppb.New(time.Now()),
Id: "b",
}
loc3 := &pb.Location{
Latitude: &wrapperspb.DoubleValue{Value: 52.6007},
Longitude: &wrapperspb.DoubleValue{Value: 0.2546},
Timestamp: timestamppb.New(time.Now()),
Id: loc2.Id,
}
err := h.Save(context.TODO(), &pb.SaveRequest{
Places: []*pb.Location{loc1, loc2, loc3},
}, &pb.SaveResponse{})
assert.NoError(t, err)
t.Run("OneUser", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Last(context.Background(), &pb.LastRequest{
Ids: []string{loc2.Id},
}, &rsp)
assert.NoError(t, err)
if len(rsp.Places) != 1 {
t.Fatalf("One location should be returned")
}
assert.Equal(t, loc3.Id, rsp.Places[0].Id)
assert.Equal(t, loc3.Latitude.Value, rsp.Places[0].Latitude.Value)
assert.Equal(t, loc3.Longitude.Value, rsp.Places[0].Longitude.Value)
assert.Equal(t, loc3.Timestamp.AsTime(), rsp.Places[0].Timestamp.AsTime())
})
t.Run("ManyUser", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Last(context.Background(), &pb.LastRequest{
Ids: []string{loc1.Id, loc2.Id},
}, &rsp)
assert.NoError(t, err)
if len(rsp.Places) != 2 {
t.Fatalf("Two places should be returned")
}
// sort using user_id so we can hardcode the index
sort.Slice(rsp.Places, func(i, j int) bool {
return rsp.Places[i].Id > rsp.Places[j].Id
})
assert.Equal(t, loc1.Id, rsp.Places[1].Id)
assert.Equal(t, loc1.Latitude.Value, rsp.Places[1].Latitude.Value)
assert.Equal(t, loc1.Longitude.Value, rsp.Places[1].Longitude.Value)
assert.Equal(t, loc1.Timestamp.AsTime(), rsp.Places[1].Timestamp.AsTime())
assert.Equal(t, loc3.Id, rsp.Places[0].Id)
assert.Equal(t, loc3.Latitude.Value, rsp.Places[0].Latitude.Value)
assert.Equal(t, loc3.Longitude.Value, rsp.Places[0].Longitude.Value)
assert.Equal(t, loc3.Timestamp.AsTime(), rsp.Places[0].Timestamp.AsTime())
})
}
func TestNear(t *testing.T) {
lat := &wrapperspb.DoubleValue{Value: 51.510357}
lng := &wrapperspb.DoubleValue{Value: -0.116773}
rad := &wrapperspb.DoubleValue{Value: 2.0}
inBoundsLat := &wrapperspb.DoubleValue{Value: 51.5110}
inBoundsLng := &wrapperspb.DoubleValue{Value: -0.1142}
outOfBoundsLat := &wrapperspb.DoubleValue{Value: 51.5415}
outOfBoundsLng := &wrapperspb.DoubleValue{Value: -0.0028}
tt := []struct {
Name string
Places []*pb.Location
Results []*pb.Location
QueryLatitude *wrapperspb.DoubleValue
QueryLongitude *wrapperspb.DoubleValue
QueryRadius *wrapperspb.DoubleValue
Error error
}{
{
Name: "MissingLatitude",
QueryLongitude: lng,
QueryRadius: rad,
Error: handler.ErrMissingLatitude,
},
{
Name: "MissingLongitude",
QueryLatitude: lat,
QueryRadius: rad,
Error: handler.ErrMissingLongitude,
},
{
Name: "MissingRadius",
QueryLatitude: lat,
QueryLongitude: lng,
Error: handler.ErrMissingRadius,
},
{
Name: "NoPlaces",
QueryLatitude: lat,
QueryLongitude: lng,
QueryRadius: rad,
},
{
Name: "OneWithinRadius",
QueryLatitude: lat,
QueryLongitude: lng,
QueryRadius: rad,
Places: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
&pb.Location{
Latitude: outOfBoundsLat,
Longitude: outOfBoundsLng,
Id: "out",
},
},
Results: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
},
},
{
Name: "NoneWithinRadius",
QueryLatitude: lat,
QueryLongitude: lng,
QueryRadius: &wrapperspb.DoubleValue{Value: 0.01},
Places: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
&pb.Location{
Latitude: outOfBoundsLat,
Longitude: outOfBoundsLng,
Id: "out",
},
},
},
{
Name: "TwoPlacesForUser",
QueryLatitude: lat,
QueryLongitude: lng,
QueryRadius: rad,
Places: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
&pb.Location{
Latitude: outOfBoundsLat,
Longitude: outOfBoundsLng,
Id: "out",
},
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "out",
},
},
Results: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "out",
},
},
},
{
Name: "ManyWithinRadius",
QueryLatitude: lat,
QueryLongitude: lng,
QueryRadius: &wrapperspb.DoubleValue{Value: 20},
Places: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
&pb.Location{
Latitude: outOfBoundsLat,
Longitude: outOfBoundsLng,
Id: "out",
},
},
Results: []*pb.Location{
&pb.Location{
Latitude: inBoundsLat,
Longitude: inBoundsLng,
Id: "in",
},
&pb.Location{
Latitude: outOfBoundsLat,
Longitude: outOfBoundsLng,
Id: "out",
},
},
},
}
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
h := testHandler(t)
// create the places
if len(tc.Places) > 0 {
err := h.Save(context.TODO(), &pb.SaveRequest{Places: tc.Places}, &pb.SaveResponse{})
assert.NoError(t, err)
}
// find near places
var rsp pb.ListResponse
err := h.Near(context.TODO(), &pb.NearRequest{
Latitude: tc.QueryLatitude,
Longitude: tc.QueryLongitude,
Radius: tc.QueryRadius,
}, &rsp)
assert.Equal(t, tc.Error, err)
// check the count of the results matches
if len(tc.Results) != len(rsp.Places) {
t.Errorf("Incorrect number of results returned. Expected %v, got %v", len(tc.Results), len(rsp.Places))
}
// validate the results match
sort.Slice(rsp.Places, func(i, j int) bool {
return rsp.Places[i].Id > rsp.Places[j].Id
})
sort.Slice(tc.Results, func(i, j int) bool {
return tc.Results[i].Id > tc.Results[j].Id
})
for i, r := range tc.Results {
l := rsp.Places[i]
assert.Equal(t, r.Id, l.Id)
assert.Equal(t, r.Latitude.Value, l.Latitude.Value)
assert.Equal(t, r.Longitude.Value, l.Longitude.Value)
}
})
}
}
func TestRead(t *testing.T) {
h := testHandler(t)
baseTime := time.Now().Add(time.Hour * -24)
t.Run("MissingIDs", func(t *testing.T) {
err := h.Read(context.Background(), &pb.ReadRequest{
After: timestamppb.New(baseTime),
Before: timestamppb.New(baseTime),
}, &pb.ListResponse{})
assert.Equal(t, handler.ErrMissingIDs, err)
})
t.Run("MissingAfter", func(t *testing.T) {
err := h.Read(context.Background(), &pb.ReadRequest{
Ids: []string{uuid.New().String()},
Before: timestamppb.New(baseTime),
}, &pb.ListResponse{})
assert.Equal(t, handler.ErrMissingAfter, err)
})
t.Run("MissingBefore", func(t *testing.T) {
err := h.Read(context.Background(), &pb.ReadRequest{
Ids: []string{uuid.New().String()},
After: timestamppb.New(baseTime),
}, &pb.ListResponse{})
assert.Equal(t, handler.ErrMissingBefore, err)
})
// generate some example data to work with
loc1 := &pb.Location{
Latitude: &wrapperspb.DoubleValue{Value: 51.5007},
Longitude: &wrapperspb.DoubleValue{Value: 0.1246},
Timestamp: timestamppb.New(baseTime.Add(time.Minute * 10)),
Id: "a",
}
loc2 := &pb.Location{
Latitude: &wrapperspb.DoubleValue{Value: 51.6007},
Longitude: &wrapperspb.DoubleValue{Value: 0.1546},
Timestamp: timestamppb.New(baseTime.Add(time.Minute * 20)),
Id: "b",
}
loc3 := &pb.Location{
Latitude: &wrapperspb.DoubleValue{Value: 52.6007},
Longitude: &wrapperspb.DoubleValue{Value: 0.2546},
Timestamp: timestamppb.New(baseTime.Add(time.Minute * 40)),
Id: loc2.Id,
}
err := h.Save(context.TODO(), &pb.SaveRequest{
Places: []*pb.Location{loc1, loc2, loc3},
}, &pb.SaveResponse{})
assert.NoError(t, err)
t.Run("NoMatches", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Read(context.Background(), &pb.ReadRequest{
Ids: []string{uuid.New().String()},
After: timestamppb.New(baseTime),
Before: timestamppb.New(baseTime.Add(time.Hour)),
}, &rsp)
assert.NoError(t, err)
assert.Empty(t, rsp.Places)
})
t.Run("OnePlaceID", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Read(context.Background(), &pb.ReadRequest{
Ids: []string{loc2.Id},
After: timestamppb.New(baseTime),
Before: timestamppb.New(baseTime.Add(time.Hour)),
}, &rsp)
assert.NoError(t, err)
if len(rsp.Places) != 2 {
t.Fatalf("Two places should be returned")
}
assert.Equal(t, loc2.Id, rsp.Places[0].Id)
assert.Equal(t, loc2.Latitude.Value, rsp.Places[0].Latitude.Value)
assert.Equal(t, loc2.Longitude.Value, rsp.Places[0].Longitude.Value)
assert.Equal(t, loc2.Timestamp.AsTime(), rsp.Places[0].Timestamp.AsTime())
assert.Equal(t, loc3.Id, rsp.Places[1].Id)
assert.Equal(t, loc3.Latitude.Value, rsp.Places[1].Latitude.Value)
assert.Equal(t, loc3.Longitude.Value, rsp.Places[1].Longitude.Value)
assert.Equal(t, loc3.Timestamp.AsTime(), rsp.Places[1].Timestamp.AsTime())
})
t.Run("OnePlaceIDReducedTime", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Read(context.Background(), &pb.ReadRequest{
Ids: []string{loc2.Id},
After: timestamppb.New(baseTime),
Before: timestamppb.New(baseTime.Add(time.Minute * 30)),
}, &rsp)
assert.NoError(t, err)
if len(rsp.Places) != 1 {
t.Fatalf("One location should be returned")
}
assert.Equal(t, loc2.Id, rsp.Places[0].Id)
assert.Equal(t, loc2.Latitude.Value, rsp.Places[0].Latitude.Value)
assert.Equal(t, loc2.Longitude.Value, rsp.Places[0].Longitude.Value)
assert.Equal(t, loc2.Timestamp.AsTime(), rsp.Places[0].Timestamp.AsTime())
})
t.Run("TwoPlaceIDs", func(t *testing.T) {
var rsp pb.ListResponse
err := h.Read(context.Background(), &pb.ReadRequest{
Ids: []string{loc1.Id, loc2.Id},
After: timestamppb.New(baseTime),
Before: timestamppb.New(baseTime.Add(time.Minute * 30)),
}, &rsp)
assert.NoError(t, err)
if len(rsp.Places) != 2 {
t.Fatalf("Two places should be returned")
}
assert.Equal(t, loc1.Id, rsp.Places[0].Id)
assert.Equal(t, loc1.Latitude.Value, rsp.Places[0].Latitude.Value)
assert.Equal(t, loc1.Longitude.Value, rsp.Places[0].Longitude.Value)
assert.Equal(t, loc1.Timestamp.AsTime(), rsp.Places[0].Timestamp.AsTime())
assert.Equal(t, loc2.Id, rsp.Places[1].Id)
assert.Equal(t, loc2.Latitude.Value, rsp.Places[1].Latitude.Value)
assert.Equal(t, loc2.Longitude.Value, rsp.Places[1].Longitude.Value)
assert.Equal(t, loc2.Timestamp.AsTime(), rsp.Places[1].Timestamp.AsTime())
})
}