[#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