diff --git a/internal/server/scripts.go b/internal/server/scripts.go index c30ad0a4..c37708ec 100644 --- a/internal/server/scripts.go +++ b/internal/server/scripts.go @@ -620,6 +620,8 @@ func (c *Server) commandInScript(msg *Message) ( res, err = c.cmdType(msg) case "keys": res, err = c.cmdKeys(msg) + case "test": + res, err = c.cmdTest(msg) } return } @@ -668,7 +670,7 @@ func (c *Server) luaTile38AtomicRW(msg *Message) (resp.Value, error) { return resp.NullValue(), errReadOnly } case "get", "keys", "scan", "nearby", "within", "intersects", "hooks", "search", - "ttl", "bounds", "server", "info", "type", "jget": + "ttl", "bounds", "server", "info", "type", "jget", "test": // read operations if c.config.followHost() != "" && !c.fcuponce { return resp.NullValue(), errCatchingUp @@ -700,7 +702,7 @@ func (c *Server) luaTile38AtomicRO(msg *Message) (resp.Value, error) { return resp.NullValue(), errReadOnly case "get", "keys", "scan", "nearby", "within", "intersects", "hooks", "search", - "ttl", "bounds", "server", "info", "type", "jget": + "ttl", "bounds", "server", "info", "type", "jget", "test": // read operations if c.config.followHost() != "" && !c.fcuponce { return resp.NullValue(), errCatchingUp @@ -735,7 +737,7 @@ func (c *Server) luaTile38NonAtomic(msg *Message) (resp.Value, error) { return resp.NullValue(), errReadOnly } case "get", "keys", "scan", "nearby", "within", "intersects", "hooks", "search", - "ttl", "bounds", "server", "info", "type", "jget": + "ttl", "bounds", "server", "info", "type", "jget", "test": // read operations c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/server/server.go b/internal/server/server.go index 69471948..4f94571e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1203,6 +1203,8 @@ func (server *Server) command(msg *Message, client *Client) ( res, err = server.cmdPsubscribe(msg) case "publish": res, err = server.cmdPublish(msg) + case "test": + res, err = server.cmdTest(msg) } return } diff --git a/internal/server/test.go b/internal/server/test.go new file mode 100644 index 00000000..6aecae33 --- /dev/null +++ b/internal/server/test.go @@ -0,0 +1,300 @@ +// TEST command: spatial tests without walking the tree. +package server + +import ( + "bytes" + "fmt" + "strconv" + "strings" + "time" + + "github.com/mmcloughlin/geohash" + "github.com/tidwall/geojson" + "github.com/tidwall/geojson/geometry" + "github.com/tidwall/resp" + "github.com/tidwall/tile38/internal/bing" + "github.com/tidwall/tile38/internal/clip" +) + +func (s * Server) parseArea(ovs[]string, doClip bool) (vs []string, o geojson.Object, err error) { + var ok bool + var typ string + vs = ovs[:] + if vs, typ, ok = tokenval(vs); !ok || typ == "" { + err = errInvalidNumberOfArguments + return + } + ltyp := strings.ToLower(typ) + switch ltyp { + case "point": + var slat, slon string + if vs, slat, ok = tokenval(vs); !ok || slat == "" { + err = errInvalidNumberOfArguments + return + } + if vs, slon, ok = tokenval(vs); !ok || slon == "" { + err = errInvalidNumberOfArguments + return + } + var lat, lon float64 + if lat, err = strconv.ParseFloat(slat, 64); err != nil { + err = errInvalidArgument(slat) + return + } + if lon, err = strconv.ParseFloat(slon, 64); err != nil { + err = errInvalidArgument(slon) + return + } + o = geojson.NewPoint(geometry.Point{X: lon, Y: lat}) + case "circle": + if doClip { + err = errInvalidArgument("cannot clip with " + ltyp) + return + } + var slat, slon, smeters string + if vs, slat, ok = tokenval(vs); !ok || slat == "" { + err = errInvalidNumberOfArguments + return + } + if vs, slon, ok = tokenval(vs); !ok || slon == "" { + err = errInvalidNumberOfArguments + return + } + var lat, lon, meters float64 + if lat, err = strconv.ParseFloat(slat, 64); err != nil { + err = errInvalidArgument(slat) + return + } + if lon, err = strconv.ParseFloat(slon, 64); err != nil { + err = errInvalidArgument(slon) + return + } + if vs, smeters, ok = tokenval(vs); !ok || smeters == "" { + err = errInvalidNumberOfArguments + return + } + if meters, err = strconv.ParseFloat(smeters, 64); err != nil { + err = errInvalidArgument(smeters) + return + } + if meters < 0 { + err = errInvalidArgument(smeters) + return + } + o = geojson.NewCircle(geometry.Point{X: lon, Y: lat}, meters, defaultCircleSteps) + case "object": + if doClip { + err = errInvalidArgument("cannot clip with " + ltyp) + return + } + var obj string + if vs, obj, ok = tokenval(vs); !ok || obj == "" { + err = errInvalidNumberOfArguments + return + } + o, err = geojson.Parse(obj, &s.geomParseOpts) + if err != nil { + return + } + case "bounds": + var sminLat, sminLon, smaxlat, smaxlon string + if vs, sminLat, ok = tokenval(vs); !ok || sminLat == "" { + err = errInvalidNumberOfArguments + return + } + if vs, sminLon, ok = tokenval(vs); !ok || sminLon == "" { + err = errInvalidNumberOfArguments + return + } + if vs, smaxlat, ok = tokenval(vs); !ok || smaxlat == "" { + err = errInvalidNumberOfArguments + return + } + if vs, smaxlon, ok = tokenval(vs); !ok || smaxlon == "" { + err = errInvalidNumberOfArguments + return + } + var minLat, minLon, maxLat, maxLon float64 + if minLat, err = strconv.ParseFloat(sminLat, 64); err != nil { + err = errInvalidArgument(sminLat) + return + } + if minLon, err = strconv.ParseFloat(sminLon, 64); err != nil { + err = errInvalidArgument(sminLon) + return + } + if maxLat, err = strconv.ParseFloat(smaxlat, 64); err != nil { + err = errInvalidArgument(smaxlat) + return + } + if maxLon, err = strconv.ParseFloat(smaxlon, 64); err != nil { + err = errInvalidArgument(smaxlon) + return + } + o = geojson.NewRect(geometry.Rect{ + Min: geometry.Point{X: minLon, Y: minLat}, + Max: geometry.Point{X: maxLon, Y: maxLat}, + }) + case "hash": + var hash string + if vs, hash, ok = tokenval(vs); !ok || hash == "" { + err = errInvalidNumberOfArguments + return + } + box := geohash.BoundingBox(hash) + o = geojson.NewRect(geometry.Rect{ + Min: geometry.Point{X: box.MinLng, Y: box.MinLat}, + Max: geometry.Point{X: box.MaxLng, Y: box.MaxLat}, + }) + case "quadkey": + var key string + if vs, key, ok = tokenval(vs); !ok || key == "" { + err = errInvalidNumberOfArguments + return + } + var minLat, minLon, maxLat, maxLon float64 + minLat, minLon, maxLat, maxLon, err = bing.QuadKeyToBounds(key) + if err != nil { + err = errInvalidArgument(key) + return + } + o = geojson.NewRect(geometry.Rect{ + Min: geometry.Point{X: minLon, Y: minLat}, + Max: geometry.Point{X: maxLon, Y: maxLat}, + }) + case "tile": + var sx, sy, sz string + if vs, sx, ok = tokenval(vs); !ok || sx == "" { + err = errInvalidNumberOfArguments + return + } + if vs, sy, ok = tokenval(vs); !ok || sy == "" { + err = errInvalidNumberOfArguments + return + } + if vs, sz, ok = tokenval(vs); !ok || sz == "" { + err = errInvalidNumberOfArguments + return + } + var x, y int64 + var z uint64 + if x, err = strconv.ParseInt(sx, 10, 64); err != nil { + err = errInvalidArgument(sx) + return + } + if y, err = strconv.ParseInt(sy, 10, 64); err != nil { + err = errInvalidArgument(sy) + return + } + if z, err = strconv.ParseUint(sz, 10, 64); err != nil { + err = errInvalidArgument(sz) + return + } + var minLat, minLon, maxLat, maxLon float64 + minLat, minLon, maxLat, maxLon = bing.TileXYToBounds(x, y, z) + o = geojson.NewRect(geometry.Rect{ + Min: geometry.Point{X: minLon, Y: minLat}, + Max: geometry.Point{X: maxLon, Y: maxLat}, + }) + case "get": + if doClip { + err = errInvalidArgument("cannot clip with " + ltyp) + return + } + var key, id string + if vs, key, ok = tokenval(vs); !ok || key == "" { + err = errInvalidNumberOfArguments + return + } + if vs, id, ok = tokenval(vs); !ok || id == "" { + err = errInvalidNumberOfArguments + return + } + col := s.getCol(key) + if col == nil { + err = errKeyNotFound + return + } + o, _, ok = col.Get(id) + if !ok { + err = errIDNotFound + return + } + } + return +} + +func (s *Server) cmdTest (msg *Message) (res resp.Value, err error) { + start := time.Now() + vs := msg.Args[1:] + + var ok bool + var test string + var obj1, obj2, clipped geojson.Object + if vs, obj1, err = s.parseArea(vs, false); err != nil { + return + } + if vs, test, ok = tokenval(vs); !ok || test == "" { + err = errInvalidNumberOfArguments + return + } + lTest := strings.ToLower(test) + if lTest != "within" && lTest != "intersects" { + err = errInvalidArgument(test) + return + } + var wtok string + var nvs []string + var doClip bool + nvs, wtok, ok = tokenval(vs) + if ok && len(wtok) > 0 { + switch strings.ToLower(wtok) { + case "clip": + vs = nvs + if lTest != "intersects" { + err = errInvalidArgument("cannot clip with" + wtok) + return + } + doClip = true + } + } + if vs, obj2, err = s.parseArea(vs, doClip); err != nil { + return + } + if len(vs) != 0 { + err = errInvalidNumberOfArguments + } + + var result int + if lTest == "within" { + if obj1.Within(obj2) { + result = 1 + } + } else if lTest == "intersects" { + if obj1.Intersects(obj2) { + result = 1 + if doClip { + clipped = clip.Clip(obj1, obj2) + } + } + } + switch msg.OutputType { + case JSON: + var buf bytes.Buffer + buf.WriteString(`{"ok":true`) + buf.WriteString(fmt.Sprintf(`,"result":%d`, result)) + if clipped != nil { + buf.WriteString(`,"object":` + clipped.JSON()) + } + buf.WriteString(`,"elapsed":"` + time.Now().Sub(start).String() + "\"}") + return resp.StringValue(buf.String()), nil + case RESP: + if clipped != nil { + return resp.ArrayValue([]resp.Value{ + resp.IntegerValue(result), + resp.StringValue(clipped.JSON())}), nil + } + return resp.IntegerValue(result), nil + } + return NOMessage, nil +}