[#2309] added query by filter record helpers

This commit is contained in:
Gani Georgiev 2023-05-31 11:47:16 +03:00
parent 0fb92720f8
commit ddca49ba16
5 changed files with 285 additions and 5 deletions

View File

@ -25,6 +25,12 @@
- Added `subscriptions.Client.Unset()` helper to remove a single cached item from the client store. - Added `subscriptions.Client.Unset()` helper to remove a single cached item from the client store.
- Added query by filter record `Dao` helpers:
```
app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true")
```
## v0.16.4-WIP ## v0.16.4-WIP

View File

@ -1,6 +1,7 @@
package daos package daos
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@ -8,8 +9,10 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast" "github.com/spf13/cast"
@ -218,6 +221,92 @@ func (dao *Dao) FindFirstRecordByData(
return record, nil return record, nil
} }
// FindRecordsByFilter returns limit number of records matching the
// provided string filter.
//
// The sort argument is optional and can be empty string OR the same format
// used in the web APIs, eg. "-created,title".
//
// If the limit argument is <= 0, no limit is applied to the query and
// all matching records are returned.
//
// Example:
//
// dao.FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
func (dao *Dao) FindRecordsByFilter(
collectionNameOrId string,
filter string,
sort string,
limit int,
) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
q := dao.RecordQuery(collection)
// build a fields resolver and attach the generated conditions to the query
// ---
resolver := resolvers.NewRecordFieldResolver(
dao,
collection, // the base collection
nil, // no request data
true, // allow searching hidden/protected fields like "email"
)
expr, err := search.FilterData(filter).BuildExpr(resolver)
if err != nil || expr == nil {
return nil, errors.New("invalid or empty filter expression")
}
q.AndWhere(expr)
if sort != "" {
for _, sortField := range search.ParseSortFromString(sort) {
expr, err := sortField.BuildExpr(resolver)
if err != nil {
return nil, err
}
if expr != "" {
q.AndOrderBy(expr)
}
}
}
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
// ---
if limit > 0 {
q.Limit(int64(limit))
}
records := []*models.Record{}
if err := q.All(&records); err != nil {
return nil, err
}
return records, nil
}
// FindFirstRecordByFilter returns the first available record matching the provided filter.
//
// Example:
//
// dao.FindFirstRecordByFilter("posts", "slug='test'")
func (dao *Dao) FindFirstRecordByFilter(collectionNameOrId string, filter string) (*models.Record, error) {
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, sql.ErrNoRows
}
return result[0], nil
}
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value. // IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
// //
// For correctness, if the collection is "auth" and the key is "username", // For correctness, if the collection is "auth" and the key is "username",

View File

@ -405,6 +405,183 @@ func TestFindFirstRecordByData(t *testing.T) {
} }
} }
func TestFindRecordsByFilter(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
collectionIdOrName string
filter string
sort string
limit int
expectError bool
expectRecordIds []string
}{
{
"missing collection",
"missing",
"id != ''",
"",
0,
true,
nil,
},
{
"missing filter",
"demo2",
"",
"",
0,
true,
nil,
},
{
"invalid filter",
"demo2",
"someMissingField > 1",
"",
0,
true,
nil,
},
{
"simple filter",
"demo2",
"id != ''",
"",
0,
false,
[]string{
"llvuca81nly1qls",
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
},
{
"multi-condition filter with sort",
"demo2",
"id != '' && active=true",
"-created,title",
-1, // should behave the same as 0
false,
[]string{
"0yxhwia2amd8gec",
"achvryl401bhse3",
},
},
{
"with limit",
"demo2",
"id != ''",
"title",
2,
false,
[]string{
"llvuca81nly1qls",
"achvryl401bhse3",
},
},
}
for _, s := range scenarios {
records, err := app.Dao().FindRecordsByFilter(
s.collectionIdOrName,
s.filter,
s.sort,
s.limit,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if len(records) != len(s.expectRecordIds) {
t.Errorf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
continue
}
for i, id := range s.expectRecordIds {
if id != records[i].Id {
t.Errorf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
}
}
}
}
func TestFindFirstRecordByFilter(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
collectionIdOrName string
filter string
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"id != ''",
true,
"",
},
{
"missing filter",
"demo2",
"",
true,
"",
},
{
"invalid filter",
"demo2",
"someMissingField > 1",
true,
"",
},
{
"valid filter but no matches",
"demo2",
"id = 'test'",
true,
"",
},
{
"valid filter and multiple matches",
"demo2",
"id != ''",
false,
"llvuca81nly1qls",
},
}
for _, s := range scenarios {
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Id != s.expectRecordId {
t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
func TestIsRecordValueUnique(t *testing.T) { func TestIsRecordValueUnique(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()

View File

@ -7,7 +7,6 @@ import (
"strings" "strings"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
@ -39,6 +38,15 @@ var plainRequestAuthFields = []string{
// ensure that `search.FieldResolver` interface is implemented // ensure that `search.FieldResolver` interface is implemented
var _ search.FieldResolver = (*RecordFieldResolver)(nil) var _ search.FieldResolver = (*RecordFieldResolver)(nil)
// CollectionsFinder defines a common interface for retrieving
// collections and other related models.
//
// The interface at the moment is primarily used to avoid circular
// dependency with the daos.Dao package.
type CollectionsFinder interface {
FindCollectionByNameOrId(collectionNameOrId string) (*models.Collection, error)
}
// RecordFieldResolver defines a custom search resolver struct for // RecordFieldResolver defines a custom search resolver struct for
// managing Record model search fields. // managing Record model search fields.
// //
@ -54,7 +62,7 @@ var _ search.FieldResolver = (*RecordFieldResolver)(nil)
// provider := search.NewProvider(resolver) // provider := search.NewProvider(resolver)
// ... // ...
type RecordFieldResolver struct { type RecordFieldResolver struct {
dao *daos.Dao dao CollectionsFinder
baseCollection *models.Collection baseCollection *models.Collection
allowHiddenFields bool allowHiddenFields bool
allowedFields []string allowedFields []string
@ -66,7 +74,7 @@ type RecordFieldResolver struct {
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`. // NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
func NewRecordFieldResolver( func NewRecordFieldResolver(
dao *daos.Dao, dao CollectionsFinder,
baseCollection *models.Collection, baseCollection *models.Collection,
requestData *models.RequestData, requestData *models.RequestData,
allowHiddenFields bool, allowHiddenFields bool,

View File

@ -44,7 +44,7 @@ func (f FilterData) BuildExpr(fieldResolver FieldResolver) (dbx.Expression, erro
func (f FilterData) build(data []fexpr.ExprGroup, fieldResolver FieldResolver) (dbx.Expression, error) { func (f FilterData) build(data []fexpr.ExprGroup, fieldResolver FieldResolver) (dbx.Expression, error) {
if len(data) == 0 { if len(data) == 0 {
return nil, errors.New("Empty filter expression.") return nil, errors.New("empty filter expression")
} }
result := &concatExpr{separator: " "} result := &concatExpr{separator: " "}
@ -61,7 +61,7 @@ func (f FilterData) build(data []fexpr.ExprGroup, fieldResolver FieldResolver) (
case []fexpr.ExprGroup: case []fexpr.ExprGroup:
expr, exprErr = f.build(item, fieldResolver) expr, exprErr = f.build(item, fieldResolver)
default: default:
exprErr = errors.New("Unsupported expression item.") exprErr = errors.New("unsupported expression item")
} }
if exprErr != nil { if exprErr != nil {