added new geoPoint field
This commit is contained in:
+60
-7
@@ -249,13 +249,55 @@ func buildResolversExpr(
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
// @todo test and docs
|
||||
var filterFunctions = map[string]func(
|
||||
argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error),
|
||||
args ...fexpr.Token,
|
||||
) (*ResolverResult, error){
|
||||
// geoDistance(lonA, latA, lonB, latB) calculates the Haversine
|
||||
// distance between 2 coordinates in metres
|
||||
// (https://www.movable-type.co.uk/scripts/latlong.html).
|
||||
"geoDistance": func(argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), args ...fexpr.Token) (*ResolverResult, error) {
|
||||
if len(args) != 4 {
|
||||
return nil, fmt.Errorf("[geoDistance] expected 4 arguments, got %d", len(args))
|
||||
}
|
||||
|
||||
resolvedArgs := make([]*ResolverResult, 4)
|
||||
for i, arg := range args {
|
||||
if arg.Type != fexpr.TokenIdentifier && arg.Type != fexpr.TokenNumber && arg.Type != fexpr.TokenFunction {
|
||||
return nil, fmt.Errorf("[geoDistance] argument %d must be an identifier, number or function", i)
|
||||
}
|
||||
resolved, err := argTokenResolverFunc(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[geoDistance] failed to resolve argument %d: %w", i, err)
|
||||
}
|
||||
resolvedArgs[i] = resolved
|
||||
}
|
||||
|
||||
lonA := resolvedArgs[0].Identifier
|
||||
latA := resolvedArgs[1].Identifier
|
||||
lonB := resolvedArgs[2].Identifier
|
||||
latB := resolvedArgs[3].Identifier
|
||||
|
||||
return &ResolverResult{
|
||||
NoCoalesce: true,
|
||||
Identifier: `(6371 * acos(` +
|
||||
`cos(radians(` + latA + `)) * cos(radians(` + latB + `)) * ` +
|
||||
`cos(radians(` + lonB + `) - radians(` + lonA + `)) + ` +
|
||||
`sin(radians(` + latA + `)) * sin(radians(` + latB + `))` +
|
||||
`))`,
|
||||
Params: mergeParams(resolvedArgs[0].Params, resolvedArgs[1].Params, resolvedArgs[2].Params, resolvedArgs[3].Params),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResult, error) {
|
||||
switch token.Type {
|
||||
case fexpr.TokenIdentifier:
|
||||
// check for macros
|
||||
// ---
|
||||
if macroFunc, ok := identifierMacros[token.Literal]; ok {
|
||||
placeholder := "t" + security.PseudorandomString(5)
|
||||
placeholder := "t" + security.PseudorandomString(8)
|
||||
|
||||
macroValue, err := macroFunc()
|
||||
if err != nil {
|
||||
@@ -272,6 +314,7 @@ func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResu
|
||||
// ---
|
||||
result, err := fieldResolver.Resolve(token.Literal)
|
||||
|
||||
// @todo replace with strings.EqualFold
|
||||
if err != nil || result.Identifier == "" {
|
||||
m := map[string]string{
|
||||
// if `null` field is missing, treat `null` identifier as NULL token
|
||||
@@ -289,22 +332,32 @@ func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResu
|
||||
|
||||
return result, err
|
||||
case fexpr.TokenText:
|
||||
placeholder := "t" + security.PseudorandomString(5)
|
||||
placeholder := "t" + security.PseudorandomString(8)
|
||||
|
||||
return &ResolverResult{
|
||||
Identifier: "{:" + placeholder + "}",
|
||||
Params: dbx.Params{placeholder: token.Literal},
|
||||
}, nil
|
||||
case fexpr.TokenNumber:
|
||||
placeholder := "t" + security.PseudorandomString(5)
|
||||
placeholder := "t" + security.PseudorandomString(8)
|
||||
|
||||
return &ResolverResult{
|
||||
Identifier: "{:" + placeholder + "}",
|
||||
Params: dbx.Params{placeholder: cast.ToFloat64(token.Literal)},
|
||||
}, nil
|
||||
case fexpr.TokenFunction:
|
||||
f, ok := filterFunctions[token.Literal]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown function %q", token.Literal)
|
||||
}
|
||||
|
||||
args, _ := token.Meta.([]fexpr.Token)
|
||||
return f(func(argToken fexpr.Token) (*ResolverResult, error) {
|
||||
return resolveToken(argToken, fieldResolver)
|
||||
}, args...)
|
||||
}
|
||||
|
||||
return nil, errors.New("unresolvable token type")
|
||||
return nil, fmt.Errorf("unsupported token type %q", token.Type)
|
||||
}
|
||||
|
||||
// Resolves = and != expressions in an attempt to minimize the COALESCE
|
||||
@@ -614,8 +667,8 @@ func (e *manyVsManyExpr) Build(db *dbx.DB, params dbx.Params) string {
|
||||
return "0=1"
|
||||
}
|
||||
|
||||
lAlias := "__ml" + security.PseudorandomString(5)
|
||||
rAlias := "__mr" + security.PseudorandomString(5)
|
||||
lAlias := "__ml" + security.PseudorandomString(8)
|
||||
rAlias := "__mr" + security.PseudorandomString(8)
|
||||
|
||||
whereExpr, buildErr := buildResolversExpr(
|
||||
&ResolverResult{
|
||||
@@ -671,7 +724,7 @@ func (e *manyVsOneExpr) Build(db *dbx.DB, params dbx.Params) string {
|
||||
return "0=1"
|
||||
}
|
||||
|
||||
alias := "__sm" + security.PseudorandomString(5)
|
||||
alias := "__sm" + security.PseudorandomString(8)
|
||||
|
||||
r1 := &ResolverResult{
|
||||
NoCoalesce: e.noCoalesce,
|
||||
|
||||
@@ -139,6 +139,12 @@ func TestFilterDataBuildExpr(t *testing.T) {
|
||||
false,
|
||||
"((COALESCE([[test1]], '') = COALESCE([[test2]], '') OR COALESCE([[test2]], '') IS NOT COALESCE([[test3]], '')) AND ([[test2]] LIKE {:TEST} ESCAPE '\\' OR [[test2]] NOT LIKE {:TEST} ESCAPE '\\') AND {:TEST} LIKE ('%' || [[test1]] || '%') ESCAPE '\\' AND {:TEST} NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\\' AND [[test3]] > {:TEST} AND [[test3]] >= {:TEST} AND [[test3]] <= {:TEST} AND {:TEST} < {:TEST})",
|
||||
},
|
||||
{
|
||||
"geoDistance function",
|
||||
"geoDistance(1,2,3,4) < 567",
|
||||
false,
|
||||
"(6371 * acos(cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))) < {:TEST}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GeoPoint defines a struct for storing geo coordinates as serialized json object
|
||||
// (e.g. {lon:0,lat:0}).
|
||||
//
|
||||
// Note: using object notation and not a plain array to avoid the confusion
|
||||
// as there doesn't seem to be a fixed standard for the coordinates order.
|
||||
type GeoPoint struct {
|
||||
Lon float64 `form:"lon" json:"lon"`
|
||||
Lat float64 `form:"lat" json:"lat"`
|
||||
}
|
||||
|
||||
// String returns the string representation of the current GeoPoint instance.
|
||||
func (p GeoPoint) String() string {
|
||||
raw, _ := json.Marshal(p)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// Value implements the [driver.Valuer] interface.
|
||||
func (p GeoPoint) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(p)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// Scan implements [sql.Scanner] interface to scan the provided value
|
||||
// into the current GeoPoint instance.
|
||||
//
|
||||
// The value argument could be nil (no-op), another GeoPoint instance,
|
||||
// map or serialized json object with lat-lon props.
|
||||
func (p *GeoPoint) Scan(value any) error {
|
||||
var err error
|
||||
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
// no cast needed
|
||||
case *GeoPoint:
|
||||
p.Lon = v.Lon
|
||||
p.Lat = v.Lat
|
||||
case GeoPoint:
|
||||
p.Lon = v.Lon
|
||||
p.Lat = v.Lat
|
||||
case JSONRaw:
|
||||
if len(v) != 0 {
|
||||
err = json.Unmarshal(v, p)
|
||||
}
|
||||
case []byte:
|
||||
if len(v) != 0 {
|
||||
err = json.Unmarshal(v, p)
|
||||
}
|
||||
case string:
|
||||
if len(v) != 0 {
|
||||
err = json.Unmarshal([]byte(v), p)
|
||||
}
|
||||
default:
|
||||
var raw []byte
|
||||
raw, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("unable to marshalize value for scanning: %w", err)
|
||||
} else {
|
||||
err = json.Unmarshal(raw, p)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("[GeoPoint] unable to scan value %v: %w", value, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestGeoPointStringAndValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
point types.GeoPoint
|
||||
expected string
|
||||
}{
|
||||
{"zero", types.GeoPoint{}, `{"lon":0,"lat":0}`},
|
||||
{"non-zero", types.GeoPoint{Lon: -10, Lat: 20.123}, `{"lon":-10,"lat":20.123}`},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
str := s.point.String()
|
||||
|
||||
val, err := s.point.Value()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if str != val {
|
||||
t.Fatalf("Expected String and Value to return the same value")
|
||||
}
|
||||
|
||||
if str != s.expected {
|
||||
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, str)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointScan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expectErr bool
|
||||
expectStr string
|
||||
}{
|
||||
{nil, false, `{"lon":1,"lat":2}`},
|
||||
{"", false, `{"lon":1,"lat":2}`},
|
||||
{types.JSONRaw{}, false, `{"lon":1,"lat":2}`},
|
||||
{[]byte{}, false, `{"lon":1,"lat":2}`},
|
||||
{`{}`, false, `{"lon":1,"lat":2}`},
|
||||
{`[]`, true, `{"lon":1,"lat":2}`},
|
||||
{0, true, `{"lon":1,"lat":2}`},
|
||||
{`{"lon":1.23,"lat":4.56}`, false, `{"lon":1.23,"lat":4.56}`},
|
||||
{[]byte(`{"lon":1.23,"lat":4.56}`), false, `{"lon":1.23,"lat":4.56}`},
|
||||
{types.JSONRaw(`{"lon":1.23,"lat":4.56}`), false, `{"lon":1.23,"lat":4.56}`},
|
||||
{types.GeoPoint{}, false, `{"lon":0,"lat":0}`},
|
||||
{types.GeoPoint{Lon: 1.23, Lat: 4.56}, false, `{"lon":1.23,"lat":4.56}`},
|
||||
{&types.GeoPoint{Lon: 1.23, Lat: 4.56}, false, `{"lon":1.23,"lat":4.56}`},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
|
||||
point := types.GeoPoint{Lon: 1, Lat: 2}
|
||||
|
||||
err := point.Scan(s.value)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectErr {
|
||||
t.Errorf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err)
|
||||
}
|
||||
|
||||
if str := point.String(); str != s.expectStr {
|
||||
t.Errorf("Expected\n%s\ngot\n%s", s.expectStr, str)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user