From ddca49ba16fa308860c13475b2fce5306643d614 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Wed, 31 May 2023 11:47:16 +0300 Subject: [PATCH] [#2309] added query by filter record helpers --- CHANGELOG.md | 6 + daos/record.go | 89 +++++++++++++++ daos/record_test.go | 177 +++++++++++++++++++++++++++++ resolvers/record_field_resolver.go | 14 ++- tools/search/filter.go | 4 +- 5 files changed, 285 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9c343c..9f851c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ - 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 diff --git a/daos/record.go b/daos/record.go index cd2e2850..8d91eea2 100644 --- a/daos/record.go +++ b/daos/record.go @@ -1,6 +1,7 @@ package daos import ( + "database/sql" "errors" "fmt" "strings" @@ -8,8 +9,10 @@ import ( "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/types" "github.com/spf13/cast" @@ -218,6 +221,92 @@ func (dao *Dao) FindFirstRecordByData( 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. // // For correctness, if the collection is "auth" and the key is "username", diff --git a/daos/record_test.go b/daos/record_test.go index cf393aa2..09ae281b 100644 --- a/daos/record_test.go +++ b/daos/record_test.go @@ -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) { app, _ := tests.NewTestApp() defer app.Cleanup() diff --git a/resolvers/record_field_resolver.go b/resolvers/record_field_resolver.go index c87e5beb..48c238ca 100644 --- a/resolvers/record_field_resolver.go +++ b/resolvers/record_field_resolver.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/tools/search" @@ -39,6 +38,15 @@ var plainRequestAuthFields = []string{ // ensure that `search.FieldResolver` interface is implemented 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 // managing Record model search fields. // @@ -54,7 +62,7 @@ var _ search.FieldResolver = (*RecordFieldResolver)(nil) // provider := search.NewProvider(resolver) // ... type RecordFieldResolver struct { - dao *daos.Dao + dao CollectionsFinder baseCollection *models.Collection allowHiddenFields bool allowedFields []string @@ -66,7 +74,7 @@ type RecordFieldResolver struct { // NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`. func NewRecordFieldResolver( - dao *daos.Dao, + dao CollectionsFinder, baseCollection *models.Collection, requestData *models.RequestData, allowHiddenFields bool, diff --git a/tools/search/filter.go b/tools/search/filter.go index 25291c00..cf07ce98 100644 --- a/tools/search/filter.go +++ b/tools/search/filter.go @@ -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) { if len(data) == 0 { - return nil, errors.New("Empty filter expression.") + return nil, errors.New("empty filter expression") } result := &concatExpr{separator: " "} @@ -61,7 +61,7 @@ func (f FilterData) build(data []fexpr.ExprGroup, fieldResolver FieldResolver) ( case []fexpr.ExprGroup: expr, exprErr = f.build(item, fieldResolver) default: - exprErr = errors.New("Unsupported expression item.") + exprErr = errors.New("unsupported expression item") } if exprErr != nil {