[#2309] added query by filter record helpers
This commit is contained in:
parent
0fb92720f8
commit
ddca49ba16
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue