initial v0.8 pre-release
This commit is contained in:
+38
-32
@@ -89,52 +89,39 @@ func (f FilterData) resolveTokenizedExpr(expr fexpr.Expr, fieldResolver FieldRes
|
||||
return nil, fmt.Errorf("Invalid right operand %q - %v.", expr.Right.Literal, rErr)
|
||||
}
|
||||
|
||||
// merge both operands parameters (if any)
|
||||
params := dbx.Params{}
|
||||
for k, v := range lParams {
|
||||
params[k] = v
|
||||
}
|
||||
for k, v := range rParams {
|
||||
params[k] = v
|
||||
}
|
||||
|
||||
switch expr.Op {
|
||||
case fexpr.SignEq:
|
||||
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') = COALESCE(%s, '')", lName, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') = COALESCE(%s, '')", lName, rName), mergeParams(lParams, rParams)), nil
|
||||
case fexpr.SignNeq:
|
||||
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') != COALESCE(%s, '')", lName, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') != COALESCE(%s, '')", lName, rName), mergeParams(lParams, rParams)), nil
|
||||
case fexpr.SignLike:
|
||||
// both sides are columns and therefore wrap the right side with "%" for contains like behavior
|
||||
if len(params) == 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s LIKE ('%%' || %s || '%%')", lName, rName), params), nil
|
||||
// the right side is a column and therefor wrap it with "%" for contains like behavior
|
||||
if len(rParams) == 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s LIKE ('%%' || %s || '%%')", lName, rName), lParams), nil
|
||||
}
|
||||
|
||||
// normalize operands and switch sides if the left operand is a number or text
|
||||
if len(lParams) > 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s LIKE %s", rName, lName), f.normalizeLikeParams(params)), nil
|
||||
}
|
||||
|
||||
return dbx.NewExp(fmt.Sprintf("%s LIKE %s", lName, rName), f.normalizeLikeParams(params)), nil
|
||||
return dbx.NewExp(fmt.Sprintf("%s LIKE %s", lName, rName), mergeParams(lParams, wrapLikeParams(rParams))), nil
|
||||
case fexpr.SignNlike:
|
||||
// both sides are columns and therefore wrap the right side with "%" for not-contains like behavior
|
||||
if len(params) == 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s NOT LIKE ('%%' || %s || '%%')", lName, rName), params), nil
|
||||
// the right side is a column and therefor wrap it with "%" for not-contains like behavior
|
||||
if len(rParams) == 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s NOT LIKE ('%%' || %s || '%%')", lName, rName), lParams), nil
|
||||
}
|
||||
|
||||
// normalize operands and switch sides if the left operand is a number or text
|
||||
if len(lParams) > 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s NOT LIKE %s", rName, lName), f.normalizeLikeParams(params)), nil
|
||||
// normalize operands and switch sides if the left operand is a number/text, but the right one is a column
|
||||
// (usually this shouldn't be needed, but it's kept for backward compatibility)
|
||||
if len(lParams) > 0 && len(rParams) == 0 {
|
||||
return dbx.NewExp(fmt.Sprintf("%s NOT LIKE %s", rName, lName), wrapLikeParams(lParams)), nil
|
||||
}
|
||||
|
||||
return dbx.NewExp(fmt.Sprintf("%s NOT LIKE %s", lName, rName), f.normalizeLikeParams(params)), nil
|
||||
return dbx.NewExp(fmt.Sprintf("%s NOT LIKE %s", lName, rName), mergeParams(lParams, wrapLikeParams(rParams))), nil
|
||||
case fexpr.SignLt:
|
||||
return dbx.NewExp(fmt.Sprintf("%s < %s", lName, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("%s < %s", lName, rName), mergeParams(lParams, rParams)), nil
|
||||
case fexpr.SignLte:
|
||||
return dbx.NewExp(fmt.Sprintf("%s <= %s", lName, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("%s <= %s", lName, rName), mergeParams(lParams, rParams)), nil
|
||||
case fexpr.SignGt:
|
||||
return dbx.NewExp(fmt.Sprintf("%s > %s", lName, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("%s > %s", lName, rName), mergeParams(lParams, rParams)), nil
|
||||
case fexpr.SignGte:
|
||||
return dbx.NewExp(fmt.Sprintf("%s >= %s", lName, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("%s >= %s", lName, rName), mergeParams(lParams, rParams)), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Unknown expression operator %q", expr.Op)
|
||||
@@ -190,12 +177,31 @@ func (f FilterData) resolveToken(token fexpr.Token, fieldResolver FieldResolver)
|
||||
return "", nil, errors.New("Unresolvable token type.")
|
||||
}
|
||||
|
||||
func (f FilterData) normalizeLikeParams(params dbx.Params) dbx.Params {
|
||||
// mergeParams returns new dbx.Params where each provided params item
|
||||
// is merged in the order they are specified.
|
||||
func mergeParams(params ...dbx.Params) dbx.Params {
|
||||
result := dbx.Params{}
|
||||
|
||||
for _, p := range params {
|
||||
for k, v := range p {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// wrapLikeParams wraps each provided param value string with `%`
|
||||
// if the string doesn't contains the `%` char (including its escape sequence).
|
||||
func wrapLikeParams(params dbx.Params) dbx.Params {
|
||||
result := dbx.Params{}
|
||||
|
||||
for k, v := range params {
|
||||
vStr := cast.ToString(v)
|
||||
if !strings.Contains(vStr, "%") {
|
||||
for i := 0; i < len(dbx.DefaultLikeEscape); i += 2 {
|
||||
vStr = strings.ReplaceAll(vStr, dbx.DefaultLikeEscape[i], dbx.DefaultLikeEscape[i+1])
|
||||
}
|
||||
vStr = "%" + vStr + "%"
|
||||
}
|
||||
result[k] = vStr
|
||||
|
||||
@@ -38,8 +38,16 @@ func TestFilterDataBuildExpr(t *testing.T) {
|
||||
regexp.QuoteMeta("[[test1]] LIKE ('%' || [[test2]] || '%')") +
|
||||
"$",
|
||||
},
|
||||
// reversed like with text
|
||||
// like with right column operand
|
||||
{"'lorem' ~ test1", false,
|
||||
"^" +
|
||||
regexp.QuoteMeta("{:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("} LIKE ('%' || [[test1]] || '%')") +
|
||||
"$",
|
||||
},
|
||||
// like with left column operand and text as right operand
|
||||
{"test1 ~ 'lorem'", false,
|
||||
"^" +
|
||||
regexp.QuoteMeta("[[test1]] LIKE {:") +
|
||||
".+" +
|
||||
@@ -52,8 +60,16 @@ func TestFilterDataBuildExpr(t *testing.T) {
|
||||
regexp.QuoteMeta("[[test1]] NOT LIKE ('%' || [[test2]] || '%')") +
|
||||
"$",
|
||||
},
|
||||
// reversed not like with text
|
||||
// not like with right column operand
|
||||
{"'lorem' !~ test1", false,
|
||||
"^" +
|
||||
regexp.QuoteMeta("{:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("} NOT LIKE ('%' || [[test1]] || '%')") +
|
||||
"$",
|
||||
},
|
||||
// like with left column operand and text as right operand
|
||||
{"test1 !~ 'lorem'", false,
|
||||
"^" +
|
||||
regexp.QuoteMeta("[[test1]] NOT LIKE {:") +
|
||||
".+" +
|
||||
@@ -97,11 +113,11 @@ func TestFilterDataBuildExpr(t *testing.T) {
|
||||
".+" +
|
||||
regexp.QuoteMeta("}) OR ([[test2]] NOT LIKE {:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("}))) AND ([[test1]] LIKE {:") +
|
||||
regexp.QuoteMeta("}))) AND ({:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("})) AND ([[test2]] NOT LIKE {:") +
|
||||
regexp.QuoteMeta("} LIKE ('%' || [[test1]] || '%'))) AND ({:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("})) AND ([[test3]] > {:") +
|
||||
regexp.QuoteMeta("} NOT LIKE ('%' || [[test2]] || '%'))) AND ([[test3]] > {:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("})) AND ([[test3]] >= {:") +
|
||||
".+" +
|
||||
|
||||
+14
-16
@@ -5,6 +5,7 @@ import (
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
const DefaultPerPage int = 30
|
||||
|
||||
// MaxPerPage specifies the maximum allowed search result items returned in a single page.
|
||||
const MaxPerPage int = 400
|
||||
const MaxPerPage int = 500
|
||||
|
||||
// url search query params
|
||||
const (
|
||||
@@ -38,7 +39,6 @@ type Provider struct {
|
||||
query *dbx.SelectQuery
|
||||
page int
|
||||
perPage int
|
||||
countColumn string
|
||||
sort []SortField
|
||||
filter []FilterData
|
||||
}
|
||||
@@ -53,7 +53,7 @@ type Provider struct {
|
||||
//
|
||||
// result, err := search.NewProvider(fieldResolver).
|
||||
// Query(baseQuery).
|
||||
// ParseAndExec("page=2&filter=id>0&sort=-name", &models)
|
||||
// ParseAndExec("page=2&filter=id>0&sort=-email", &models)
|
||||
func NewProvider(fieldResolver FieldResolver) *Provider {
|
||||
return &Provider{
|
||||
fieldResolver: fieldResolver,
|
||||
@@ -70,13 +70,6 @@ func (s *Provider) Query(query *dbx.SelectQuery) *Provider {
|
||||
return s
|
||||
}
|
||||
|
||||
// CountColumn specifies an optional distinct column to use in the
|
||||
// SELECT COUNT query.
|
||||
func (s *Provider) CountColumn(countColumn string) *Provider {
|
||||
s.countColumn = countColumn
|
||||
return s
|
||||
}
|
||||
|
||||
// Page sets the `page` field of the current search provider.
|
||||
//
|
||||
// Normalization on the `page` value is done during `Exec()`.
|
||||
@@ -170,7 +163,7 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||
// clone provider's query
|
||||
modelsQuery := *s.query
|
||||
|
||||
// apply filters
|
||||
// build filters
|
||||
for _, f := range s.filter {
|
||||
expr, err := f.BuildExpr(s.fieldResolver)
|
||||
if err != nil {
|
||||
@@ -197,14 +190,19 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryInfo := modelsQuery.Info()
|
||||
|
||||
// count
|
||||
var totalCount int64
|
||||
countQuery := modelsQuery
|
||||
countQuery.Distinct(false).Select("COUNT(*)").OrderBy() // unset ORDER BY statements
|
||||
if s.countColumn != "" {
|
||||
countQuery.Select("COUNT(DISTINCT(" + s.countColumn + "))")
|
||||
var baseTable string
|
||||
if len(queryInfo.From) > 0 {
|
||||
baseTable = queryInfo.From[0]
|
||||
}
|
||||
if err := countQuery.Row(&totalCount); err != nil {
|
||||
countQuery := modelsQuery
|
||||
rawCountQuery := countQuery.Select(strings.Join([]string{baseTable, "id"}, ".")).OrderBy().Build().SQL()
|
||||
wrappedCountQuery := queryInfo.Builder.NewQuery("SELECT COUNT(*) FROM (" + rawCountQuery + ")")
|
||||
wrappedCountQuery.Bind(countQuery.Build().Params())
|
||||
if err := wrappedCountQuery.Row(&totalCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -60,15 +60,6 @@ func TestProviderPerPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCountColumn(t *testing.T) {
|
||||
r := &testFieldResolver{}
|
||||
p := NewProvider(r).CountColumn("test")
|
||||
|
||||
if p.countColumn != "test" {
|
||||
t.Fatalf("Expected distinct count column %v, got %v", "test", p.countColumn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSort(t *testing.T) {
|
||||
initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}}
|
||||
r := &testFieldResolver{}
|
||||
@@ -223,7 +214,6 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
perPage int
|
||||
sort []SortField
|
||||
filter []FilterData
|
||||
countColumn string
|
||||
expectError bool
|
||||
expectResult string
|
||||
expectQueries []string
|
||||
@@ -234,11 +224,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":10,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL)",
|
||||
"SELECT COUNT(*) FROM (SELECT `test`.`id` FROM `test` WHERE NOT (`test1` IS NULL))",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10",
|
||||
},
|
||||
},
|
||||
@@ -248,11 +237,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
0, // fallback to default
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":30,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL)",
|
||||
"SELECT COUNT(*) FROM (SELECT `test`.`id` FROM `test` WHERE NOT (`test1` IS NULL))",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30",
|
||||
},
|
||||
},
|
||||
@@ -262,7 +250,6 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{{"unknown", SortAsc}},
|
||||
[]FilterData{},
|
||||
"",
|
||||
true,
|
||||
"",
|
||||
nil,
|
||||
@@ -273,7 +260,6 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{},
|
||||
[]FilterData{"test2 = 'test2.1'", "invalid"},
|
||||
"",
|
||||
true,
|
||||
"",
|
||||
nil,
|
||||
@@ -284,12 +270,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
5555, // will be limited by MaxPerPage
|
||||
[]SortField{{"test2", SortDesc}},
|
||||
[]FilterData{"test2 != null", "test1 >= 2"},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2)",
|
||||
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT 400",
|
||||
"SELECT COUNT(*) FROM (SELECT `test`.`id` FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2))",
|
||||
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT 500",
|
||||
},
|
||||
},
|
||||
// valid sort and filter fields (zero results)
|
||||
@@ -298,11 +283,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{{"test3", SortAsc}},
|
||||
[]FilterData{"test3 != ''"},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":10,"totalItems":0,"totalPages":0,"items":[]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', ''))",
|
||||
"SELECT COUNT(*) FROM (SELECT `test`.`id` FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')))",
|
||||
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
|
||||
},
|
||||
},
|
||||
@@ -312,25 +296,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
1,
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"",
|
||||
false,
|
||||
`{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL)",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
||||
},
|
||||
},
|
||||
// distinct count column
|
||||
{
|
||||
3,
|
||||
1,
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"test.test1",
|
||||
false,
|
||||
`{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(DISTINCT(test.test1)) FROM `test` WHERE NOT (`test1` IS NULL)",
|
||||
"SELECT COUNT(*) FROM (SELECT `test`.`id` FROM `test` WHERE NOT (`test1` IS NULL))",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
||||
},
|
||||
},
|
||||
@@ -345,8 +314,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
Page(s.page).
|
||||
PerPage(s.perPage).
|
||||
Sort(s.sort).
|
||||
Filter(s.filter).
|
||||
CountColumn(s.countColumn)
|
||||
Filter(s.filter)
|
||||
|
||||
result, err := p.Exec(&[]testTableStruct{})
|
||||
|
||||
@@ -376,7 +344,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
|
||||
for _, q := range testDB.CalledQueries {
|
||||
if !list.ExistInSliceWithRegex(q, s.expectQueries) {
|
||||
t.Errorf("(%d) Didn't expect query \n%v", i, q)
|
||||
t.Errorf("(%d) Didn't expect query \n%v in \n%v", i, q, testDB.CalledQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,7 +407,7 @@ func TestProviderParseAndExec(t *testing.T) {
|
||||
{
|
||||
"page=3&perPage=9999&filter=test1>1&sort=-test2,test3",
|
||||
false,
|
||||
`{"page":1,"perPage":400,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
`{"page":1,"perPage":500,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -504,9 +472,9 @@ func createTestDB() (*testDB, error) {
|
||||
}
|
||||
|
||||
db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")}
|
||||
db.CreateTable("test", map[string]string{"test1": "int default 0", "test2": "text default ''", "test3": "text default ''"}).Execute()
|
||||
db.Insert("test", dbx.Params{"test1": 1, "test2": "test2.1"}).Execute()
|
||||
db.Insert("test", dbx.Params{"test1": 2, "test2": "test2.2"}).Execute()
|
||||
db.CreateTable("test", map[string]string{"id": "int default 0", "test1": "int default 0", "test2": "text default ''", "test3": "text default ''"}).Execute()
|
||||
db.Insert("test", dbx.Params{"id": 1, "test1": 1, "test2": "test2.1"}).Execute()
|
||||
db.Insert("test", dbx.Params{"id": 2, "test1": 2, "test2": "test2.2"}).Execute()
|
||||
db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
db.CalledQueries = append(db.CalledQueries, sql)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user