[#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 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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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",
 | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue