added support to filter request.user.profile relation fields
This commit is contained in:
parent
8a08a4764d
commit
1a5180d7d3
|
@ -52,10 +52,9 @@ func (api *recordApi) list(c echo.Context) error {
|
||||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// forbid user/guest defined non-relational joins (aka. @collection.*)
|
// forbid users and guests to query special filter/sort fields
|
||||||
queryStr := c.QueryString()
|
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||||
if admin == nil && queryStr != "" && (strings.Contains(queryStr, "@collection") || strings.Contains(queryStr, "%40collection")) {
|
return err
|
||||||
return rest.NewForbiddenError("Only admins can filter by @collection.", nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requestData := api.exportRequestData(c)
|
requestData := api.exportRequestData(c)
|
||||||
|
@ -63,14 +62,15 @@ func (api *recordApi) list(c echo.Context) error {
|
||||||
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||||
|
|
||||||
searchProvider := search.NewProvider(fieldsResolver).
|
searchProvider := search.NewProvider(fieldsResolver).
|
||||||
Query(api.app.Dao().RecordQuery(collection))
|
Query(api.app.Dao().RecordQuery(collection)).
|
||||||
|
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
|
||||||
|
|
||||||
if admin == nil && collection.ListRule != nil {
|
if admin == nil && collection.ListRule != nil {
|
||||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawRecords = []dbx.NullStringMap{}
|
var rawRecords = []dbx.NullStringMap{}
|
||||||
result, err := searchProvider.ParseAndExec(queryStr, &rawRecords)
|
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rest.NewBadRequestError("Invalid filter parameters.", err)
|
return rest.NewBadRequestError("Invalid filter parameters.", err)
|
||||||
}
|
}
|
||||||
|
@ -407,6 +407,24 @@ func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||||
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||||
|
if admin != nil {
|
||||||
|
return nil // admins are allowed to query everything
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||||
|
forbiddenFields := []string{"@collection.", "@request."}
|
||||||
|
|
||||||
|
for _, field := range forbiddenFields {
|
||||||
|
if strings.Contains(decodedQuery, field) {
|
||||||
|
return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
|
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
|
||||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||||
|
|
||||||
type join struct {
|
type join struct {
|
||||||
|
id string
|
||||||
table string
|
table string
|
||||||
on dbx.Expression
|
on dbx.Expression
|
||||||
}
|
}
|
||||||
|
@ -36,7 +37,7 @@ type RecordFieldResolver struct {
|
||||||
baseCollection *models.Collection
|
baseCollection *models.Collection
|
||||||
allowedFields []string
|
allowedFields []string
|
||||||
requestData map[string]any
|
requestData map[string]any
|
||||||
joins map[string]join
|
joins []join // we cannot use a map because the insertion order is not preserved
|
||||||
loadedCollections []*models.Collection
|
loadedCollections []*models.Collection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +51,7 @@ func NewRecordFieldResolver(
|
||||||
dao: dao,
|
dao: dao,
|
||||||
baseCollection: baseCollection,
|
baseCollection: baseCollection,
|
||||||
requestData: requestData,
|
requestData: requestData,
|
||||||
joins: make(map[string]join),
|
joins: []join{},
|
||||||
loadedCollections: []*models.Collection{baseCollection},
|
loadedCollections: []*models.Collection{baseCollection},
|
||||||
allowedFields: []string{
|
allowedFields: []string{
|
||||||
`^\w+[\w\.]*$`,
|
`^\w+[\w\.]*$`,
|
||||||
|
@ -85,6 +86,7 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||||
// id
|
// id
|
||||||
// project.screen.status
|
// project.screen.status
|
||||||
// @request.status
|
// @request.status
|
||||||
|
// @request.user.profile.someRelation.name
|
||||||
// @collection.product.name
|
// @collection.product.name
|
||||||
func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, placeholderParams dbx.Params, err error) {
|
func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, placeholderParams dbx.Params, err error) {
|
||||||
if len(r.allowedFields) > 0 && !list.ExistInSliceWithRegex(fieldName, r.allowedFields) {
|
if len(r.allowedFields) > 0 && !list.ExistInSliceWithRegex(fieldName, r.allowedFields) {
|
||||||
|
@ -93,15 +95,6 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||||
|
|
||||||
props := strings.Split(fieldName, ".")
|
props := strings.Split(fieldName, ".")
|
||||||
|
|
||||||
// check for @request field
|
|
||||||
if props[0] == "@request" {
|
|
||||||
if len(props) == 1 {
|
|
||||||
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.resolveRequestField(props[1:]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCollectionName := r.baseCollection.Name
|
currentCollectionName := r.baseCollection.Name
|
||||||
currentTableAlias := currentCollectionName
|
currentTableAlias := currentCollectionName
|
||||||
|
|
||||||
|
@ -113,16 +106,54 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||||
}
|
}
|
||||||
|
|
||||||
currentCollectionName = props[1]
|
currentCollectionName = props[1]
|
||||||
currentTableAlias = "c_" + currentCollectionName
|
currentTableAlias = "__collection_" + currentCollectionName
|
||||||
|
|
||||||
collection, err := r.loadCollection(currentCollectionName)
|
collection, err := r.loadCollection(currentCollectionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
|
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.addJoin(collection.Name, currentTableAlias, "", "", "")
|
r.addJoin(collection.Name, currentTableAlias, nil)
|
||||||
|
|
||||||
props = props[2:] // leave only the collection fields
|
props = props[2:] // leave only the collection fields
|
||||||
|
} else if props[0] == "@request" {
|
||||||
|
// check for @request field
|
||||||
|
if len(props) == 1 {
|
||||||
|
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// not a profile relational field
|
||||||
|
if len(props) <= 4 || !strings.HasPrefix(fieldName, "@request.user.profile.") {
|
||||||
|
return r.resolveStaticRequestField(props[1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve the profile collection fields
|
||||||
|
currentCollectionName = models.ProfileCollectionName
|
||||||
|
currentTableAlias = "__user_" + currentCollectionName
|
||||||
|
|
||||||
|
collection, err := r.loadCollection(currentCollectionName)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileIdPlaceholder, profileIdPlaceholderParam, err := r.resolveStaticRequestField("user", "profile", "id")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("Failed to resolve @request.user.profile.id path in %q.", fieldName)
|
||||||
|
}
|
||||||
|
if strings.ToLower(profileIdPlaceholder) == "null" {
|
||||||
|
// the user doesn't have an associated profile
|
||||||
|
return "NULL", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// join the profile collection
|
||||||
|
r.addJoin(collection.Name, currentTableAlias, dbx.NewExp(fmt.Sprintf(
|
||||||
|
// aka. profiles.id = profileId
|
||||||
|
"[[%s.id]] = %s",
|
||||||
|
inflector.Columnify(currentTableAlias),
|
||||||
|
profileIdPlaceholder,
|
||||||
|
), profileIdPlaceholderParam))
|
||||||
|
|
||||||
|
props = props[3:] // leave only the profile fields
|
||||||
}
|
}
|
||||||
|
|
||||||
baseModelFields := schema.ReservedFieldNames()
|
baseModelFields := schema.ReservedFieldNames()
|
||||||
|
@ -173,9 +204,14 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||||
r.addJoin(
|
r.addJoin(
|
||||||
newCollectionName,
|
newCollectionName,
|
||||||
newTableAlias,
|
newTableAlias,
|
||||||
"id",
|
dbx.NewExp(fmt.Sprintf(
|
||||||
currentTableAlias,
|
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
|
||||||
field.Name,
|
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
|
||||||
|
inflector.Columnify(currentTableAlias),
|
||||||
|
inflector.Columnify(field.Name),
|
||||||
|
inflector.Columnify(newTableAlias),
|
||||||
|
inflector.Columnify("id"),
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
currentCollectionName = newCollectionName
|
currentCollectionName = newCollectionName
|
||||||
|
@ -185,7 +221,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||||
return "", nil, fmt.Errorf("Failed to resolve field %q.", fieldName)
|
return "", nil, fmt.Errorf("Failed to resolve field %q.", fieldName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecordFieldResolver) resolveRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
|
func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
|
||||||
// ignore error because requestData is dynamic and some of the
|
// ignore error because requestData is dynamic and some of the
|
||||||
// lookup keys may not be defined for the request
|
// lookup keys may not be defined for the request
|
||||||
resultVal, _ := extractNestedMapVal(r.requestData, path...)
|
resultVal, _ := extractNestedMapVal(r.requestData, path...)
|
||||||
|
@ -259,24 +295,27 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models
|
||||||
return collection, nil
|
return collection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecordFieldResolver) addJoin(tableName, tableAlias, fieldName, ref, refFieldName string) {
|
func (r *RecordFieldResolver) addJoin(tableName string, tableAlias string, on dbx.Expression) {
|
||||||
table := fmt.Sprintf(
|
tableExpr := fmt.Sprintf(
|
||||||
"%s %s",
|
"%s %s",
|
||||||
inflector.Columnify(tableName),
|
inflector.Columnify(tableName),
|
||||||
inflector.Columnify(tableAlias),
|
inflector.Columnify(tableAlias),
|
||||||
)
|
)
|
||||||
|
|
||||||
var on dbx.Expression
|
join := join{
|
||||||
if ref != "" {
|
id: tableAlias,
|
||||||
on = dbx.NewExp(fmt.Sprintf(
|
table: tableExpr,
|
||||||
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
|
on: on,
|
||||||
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
|
|
||||||
inflector.Columnify(ref),
|
|
||||||
inflector.Columnify(refFieldName),
|
|
||||||
inflector.Columnify(tableAlias),
|
|
||||||
inflector.Columnify(fieldName),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.joins[tableAlias] = join{table, on}
|
// replace existing join
|
||||||
|
for i, j := range r.joins {
|
||||||
|
if j.id == join.id {
|
||||||
|
r.joins[i] = join
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// register new join
|
||||||
|
r.joins = append(r.joins, join)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,12 @@ package resolvers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/resolvers"
|
"github.com/pocketbase/pocketbase/resolvers"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||||
|
@ -18,54 +19,94 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestData := map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||||
|
"profile": map[string]any{
|
||||||
|
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
|
||||||
|
"name": "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
fieldName string
|
name string
|
||||||
expectQueryParts []string // we are matching parts of the query
|
fields []string
|
||||||
// since joins are added with map iteration and the order is not guaranteed
|
expectQuery string
|
||||||
}{
|
}{
|
||||||
// missing field
|
{
|
||||||
{"", []string{
|
"missing field",
|
||||||
|
[]string{""},
|
||||||
"SELECT `demo4`.* FROM `demo4`",
|
"SELECT `demo4`.* FROM `demo4`",
|
||||||
}},
|
},
|
||||||
// non relation field
|
{
|
||||||
{"title", []string{
|
"non relation field",
|
||||||
|
[]string{"title"},
|
||||||
"SELECT `demo4`.* FROM `demo4`",
|
"SELECT `demo4`.* FROM `demo4`",
|
||||||
}},
|
},
|
||||||
// incomplete rel
|
{
|
||||||
{"onerel", []string{
|
"incomplete rel",
|
||||||
|
[]string{"onerel"},
|
||||||
"SELECT `demo4`.* FROM `demo4`",
|
"SELECT `demo4`.* FROM `demo4`",
|
||||||
}},
|
},
|
||||||
// single rel
|
{
|
||||||
{"onerel.title", []string{
|
"single rel",
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
[]string{"onerel.title"},
|
||||||
" LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||||
}},
|
},
|
||||||
// nested incomplete rels
|
{
|
||||||
{"manyrels.onerel", []string{
|
"non-relation field + single rel",
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
[]string{"title", "onerel.title"},
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||||
}},
|
},
|
||||||
// nested complete rels
|
{
|
||||||
{"manyrels.onerel.title", []string{
|
"nested incomplete rels",
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
[]string{"manyrels.onerel"},
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
|
},
|
||||||
}},
|
{
|
||||||
// // repeated nested rels
|
"nested complete rels",
|
||||||
{"manyrels.onerel.manyrels.onerel.title", []string{
|
[]string{"manyrels.onerel.title"},
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
},
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
|
{
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel.manyrels]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels.id]] || '%')",
|
"repeated nested rels",
|
||||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels_onerel.id]] || '%')",
|
[]string{"manyrels.onerel.manyrels.onerel.title"},
|
||||||
}},
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel.manyrels]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels_onerel.id]] || '%')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple rels",
|
||||||
|
[]string{"manyrels.title", "onerel.onefile"},
|
||||||
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@collection join",
|
||||||
|
[]string{"@collection.demo.title", "@collection.demo2.text", "@collection.demo.file"},
|
||||||
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo` `__collection_demo` LEFT JOIN `demo2` `__collection_demo2`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"static @request.user.profile fields",
|
||||||
|
[]string{"@request.user.id", "@request.user.profile.id", "@request.data.demo"},
|
||||||
|
"SELECT `demo4`.* FROM `demo4`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"relational @request.user.profile fields",
|
||||||
|
[]string{"@request.user.profile.rel.id", "@request.user.profile.rel.name"},
|
||||||
|
"^" +
|
||||||
|
regexp.QuoteMeta("SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `profiles` `__user_profiles` ON [[__user_profiles.id]] =") +
|
||||||
|
" {:.*} " +
|
||||||
|
regexp.QuoteMeta("LEFT JOIN `profiles` `__user_profiles_rel` ON [[__user_profiles.rel]] LIKE ('%' || [[__user_profiles_rel.id]] || '%')") +
|
||||||
|
"$",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, s := range scenarios {
|
for i, s := range scenarios {
|
||||||
query := app.Dao().RecordQuery(collection)
|
query := app.Dao().RecordQuery(collection)
|
||||||
|
|
||||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
|
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
||||||
r.Resolve(s.fieldName)
|
for _, field := range s.fields {
|
||||||
|
r.Resolve(field)
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.UpdateQuery(query); err != nil {
|
if err := r.UpdateQuery(query); err != nil {
|
||||||
t.Errorf("(%d) UpdateQuery failed with error %v", i, err)
|
t.Errorf("(%d) UpdateQuery failed with error %v", i, err)
|
||||||
|
@ -74,16 +115,8 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||||
|
|
||||||
rawQuery := query.Build().SQL()
|
rawQuery := query.Build().SQL()
|
||||||
|
|
||||||
partsLength := 0
|
if !list.ExistInSliceWithRegex(rawQuery, []string{s.expectQuery}) {
|
||||||
for _, part := range s.expectQueryParts {
|
t.Errorf("(%d) Expected query\n %v \ngot:\n %v", i, s.expectQuery, rawQuery)
|
||||||
partsLength += len(part)
|
|
||||||
if !strings.Contains(rawQuery, part) {
|
|
||||||
t.Errorf("(%d) Part %v is missing from query \n%v", i, part, rawQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if partsLength != len(rawQuery) {
|
|
||||||
t.Errorf("(%d) Expected %d characters, got %d in \n%v", i, partsLength, len(rawQuery), rawQuery)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -97,7 +130,16 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
|
requestData := map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||||
|
"profile": map[string]any{
|
||||||
|
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
fieldName string
|
fieldName string
|
||||||
|
@ -118,42 +160,45 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
|
||||||
{"manyrels.unknown", true, ""},
|
{"manyrels.unknown", true, ""},
|
||||||
{"manyrels.title", false, "[[demo4_manyrels.title]]"},
|
{"manyrels.title", false, "[[demo4_manyrels.title]]"},
|
||||||
{"manyrels.onerel.manyrels.onefile", false, "[[demo4_manyrels_onerel_manyrels.onefile]]"},
|
{"manyrels.onerel.manyrels.onefile", false, "[[demo4_manyrels_onerel_manyrels.onefile]]"},
|
||||||
|
// @request.user.profile relation join:
|
||||||
|
{"@request.user.profile.rel.name", false, "[[__user_profiles_rel.name]]"},
|
||||||
|
// @collection fieds:
|
||||||
{"@collect", true, ""},
|
{"@collect", true, ""},
|
||||||
{"collection.demo4.title", true, ""},
|
{"collection.demo4.title", true, ""},
|
||||||
{"@collection", true, ""},
|
{"@collection", true, ""},
|
||||||
{"@collection.unknown", true, ""},
|
{"@collection.unknown", true, ""},
|
||||||
{"@collection.demo", true, ""},
|
{"@collection.demo", true, ""},
|
||||||
{"@collection.demo.", true, ""},
|
{"@collection.demo.", true, ""},
|
||||||
{"@collection.demo.title", false, "[[c_demo.title]]"},
|
{"@collection.demo.title", false, "[[__collection_demo.title]]"},
|
||||||
{"@collection.demo4.title", false, "[[c_demo4.title]]"},
|
{"@collection.demo4.title", false, "[[__collection_demo4.title]]"},
|
||||||
{"@collection.demo4.id", false, "[[c_demo4.id]]"},
|
{"@collection.demo4.id", false, "[[__collection_demo4.id]]"},
|
||||||
{"@collection.demo4.created", false, "[[c_demo4.created]]"},
|
{"@collection.demo4.created", false, "[[__collection_demo4.created]]"},
|
||||||
{"@collection.demo4.updated", false, "[[c_demo4.updated]]"},
|
{"@collection.demo4.updated", false, "[[__collection_demo4.updated]]"},
|
||||||
{"@collection.demo4.manyrels.missing", true, ""},
|
{"@collection.demo4.manyrels.missing", true, ""},
|
||||||
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[c_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
|
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[__collection_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
name, params, err := r.Resolve(s.fieldName)
|
name, params, err := r.Resolve(s.fieldName)
|
||||||
|
|
||||||
hasErr := err != nil
|
hasErr := err != nil
|
||||||
if hasErr != s.expectError {
|
if hasErr != s.expectError {
|
||||||
t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
|
t.Errorf("(%q) Expected hasErr %v, got %v (%v)", s.fieldName, s.expectError, hasErr, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if name != s.expectName {
|
if name != s.expectName {
|
||||||
t.Errorf("(%d) Expected name %q, got %q", i, s.expectName, name)
|
t.Errorf("(%q) Expected name %q, got %q", s.fieldName, s.expectName, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// params should be empty for non @request fields
|
// params should be empty for non @request fields
|
||||||
if len(params) != 0 {
|
if len(params) != 0 {
|
||||||
t.Errorf("(%d) Expected 0 params, got %v", i, params)
|
t.Errorf("(%q) Expected 0 params, got %v", s.fieldName, params)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
|
func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
@ -171,7 +216,13 @@ func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
|
||||||
"b": 456,
|
"b": 456,
|
||||||
"c": map[string]int{"sub": 1},
|
"c": map[string]int{"sub": 1},
|
||||||
},
|
},
|
||||||
"user": nil,
|
"user": map[string]any{
|
||||||
|
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||||
|
"profile": map[string]any{
|
||||||
|
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
|
||||||
|
"name": "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
||||||
|
@ -194,7 +245,9 @@ func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
|
||||||
{"@request.data.b.missing", false, ``},
|
{"@request.data.b.missing", false, ``},
|
||||||
{"@request.data.c", false, `"{\"sub\":1}"`},
|
{"@request.data.c", false, `"{\"sub\":1}"`},
|
||||||
{"@request.user", true, ""},
|
{"@request.user", true, ""},
|
||||||
{"@request.user.id", false, ""},
|
{"@request.user.id", false, `"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`},
|
||||||
|
{"@request.user.profile", false, `"{\"id\":\"d13f60a4-5765-48c7-9e1d-3e782340f833\",\"name\":\"test\"}"`},
|
||||||
|
{"@request.user.profile.name", false, `"test"`},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, s := range scenarios {
|
for i, s := range scenarios {
|
||||||
|
|
Binary file not shown.
|
@ -37,6 +37,7 @@ type Provider struct {
|
||||||
query *dbx.SelectQuery
|
query *dbx.SelectQuery
|
||||||
page int
|
page int
|
||||||
perPage int
|
perPage int
|
||||||
|
countColumn string
|
||||||
sort []SortField
|
sort []SortField
|
||||||
filter []FilterData
|
filter []FilterData
|
||||||
}
|
}
|
||||||
|
@ -67,6 +68,13 @@ func (s *Provider) Query(query *dbx.SelectQuery) *Provider {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountColumn specifies an optional distinct column to use in the
|
||||||
|
// SELECT COUNT query.
|
||||||
|
func (s *Provider) CountColumn(countColumn string) *Provider {
|
||||||
|
s.countColumn = countColumn
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// Page sets the `page` field of the current search provider.
|
// Page sets the `page` field of the current search provider.
|
||||||
//
|
//
|
||||||
// Normalization on the `page` value is done during `Exec()`.
|
// Normalization on the `page` value is done during `Exec()`.
|
||||||
|
@ -190,7 +198,11 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||||
// count
|
// count
|
||||||
var totalCount int64
|
var totalCount int64
|
||||||
countQuery := modelsQuery
|
countQuery := modelsQuery
|
||||||
if err := countQuery.Select("count(*)").Row(&totalCount); err != nil {
|
countQuery.Distinct(false).Select("COUNT(*)")
|
||||||
|
if s.countColumn != "" {
|
||||||
|
countQuery.Select("COUNT(DISTINCT(" + s.countColumn + "))")
|
||||||
|
}
|
||||||
|
if err := countQuery.Row(&totalCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,6 +60,15 @@ func TestProviderPerPage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderCountColumn(t *testing.T) {
|
||||||
|
r := &testFieldResolver{}
|
||||||
|
p := NewProvider(r).CountColumn("test")
|
||||||
|
|
||||||
|
if p.countColumn != "test" {
|
||||||
|
t.Fatalf("Expected distinct count column %v, got %v", "test", p.countColumn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderSort(t *testing.T) {
|
func TestProviderSort(t *testing.T) {
|
||||||
initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}}
|
initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}}
|
||||||
r := &testFieldResolver{}
|
r := &testFieldResolver{}
|
||||||
|
@ -214,6 +223,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
perPage int
|
perPage int
|
||||||
sort []SortField
|
sort []SortField
|
||||||
filter []FilterData
|
filter []FilterData
|
||||||
|
countColumn string
|
||||||
expectError bool
|
expectError bool
|
||||||
expectResult string
|
expectResult string
|
||||||
expectQueries []string
|
expectQueries []string
|
||||||
|
@ -224,10 +234,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
10,
|
10,
|
||||||
[]SortField{},
|
[]SortField{},
|
||||||
[]FilterData{},
|
[]FilterData{},
|
||||||
|
"",
|
||||||
false,
|
false,
|
||||||
`{"page":1,"perPage":10,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
`{"page":1,"perPage":10,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||||
[]string{
|
[]string{
|
||||||
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10",
|
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -237,10 +248,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
0, // fallback to default
|
0, // fallback to default
|
||||||
[]SortField{},
|
[]SortField{},
|
||||||
[]FilterData{},
|
[]FilterData{},
|
||||||
|
"",
|
||||||
false,
|
false,
|
||||||
`{"page":1,"perPage":30,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
`{"page":1,"perPage":30,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||||
[]string{
|
[]string{
|
||||||
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30",
|
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -250,6 +262,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
10,
|
10,
|
||||||
[]SortField{{"unknown", SortAsc}},
|
[]SortField{{"unknown", SortAsc}},
|
||||||
[]FilterData{},
|
[]FilterData{},
|
||||||
|
"",
|
||||||
true,
|
true,
|
||||||
"",
|
"",
|
||||||
nil,
|
nil,
|
||||||
|
@ -260,6 +273,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
10,
|
10,
|
||||||
[]SortField{},
|
[]SortField{},
|
||||||
[]FilterData{"test2 = 'test2.1'", "invalid"},
|
[]FilterData{"test2 = 'test2.1'", "invalid"},
|
||||||
|
"",
|
||||||
true,
|
true,
|
||||||
"",
|
"",
|
||||||
nil,
|
nil,
|
||||||
|
@ -270,10 +284,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
5555, // will be limited by MaxPerPage
|
5555, // will be limited by MaxPerPage
|
||||||
[]SortField{{"test2", SortDesc}},
|
[]SortField{{"test2", SortDesc}},
|
||||||
[]FilterData{"test2 != null", "test1 >= 2"},
|
[]FilterData{"test2 != null", "test1 >= 2"},
|
||||||
|
"",
|
||||||
false,
|
false,
|
||||||
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||||
[]string{
|
[]string{
|
||||||
"SELECT count(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC",
|
"SELECT COUNT(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC",
|
||||||
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
|
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -283,10 +298,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
10,
|
10,
|
||||||
[]SortField{{"test3", SortAsc}},
|
[]SortField{{"test3", SortAsc}},
|
||||||
[]FilterData{"test3 != ''"},
|
[]FilterData{"test3 != ''"},
|
||||||
|
"",
|
||||||
false,
|
false,
|
||||||
`{"page":1,"perPage":10,"totalItems":0,"items":[]}`,
|
`{"page":1,"perPage":10,"totalItems":0,"items":[]}`,
|
||||||
[]string{
|
[]string{
|
||||||
"SELECT count(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC",
|
"SELECT COUNT(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC",
|
||||||
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
|
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -296,10 +312,25 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
1,
|
1,
|
||||||
[]SortField{},
|
[]SortField{},
|
||||||
[]FilterData{},
|
[]FilterData{},
|
||||||
|
"",
|
||||||
false,
|
false,
|
||||||
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||||
[]string{
|
[]string{
|
||||||
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||||
|
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// distinct count column
|
||||||
|
{
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
[]SortField{},
|
||||||
|
[]FilterData{},
|
||||||
|
"test.test1",
|
||||||
|
false,
|
||||||
|
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||||
|
[]string{
|
||||||
|
"SELECT COUNT(DISTINCT(test.test1)) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -314,7 +345,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||||
Page(s.page).
|
Page(s.page).
|
||||||
PerPage(s.perPage).
|
PerPage(s.perPage).
|
||||||
Sort(s.sort).
|
Sort(s.sort).
|
||||||
Filter(s.filter)
|
Filter(s.filter).
|
||||||
|
CountColumn(s.countColumn)
|
||||||
|
|
||||||
result, err := p.Exec(&[]testTableStruct{})
|
result, err := p.Exec(&[]testTableStruct{})
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue