1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-03-19 14:17:48 +02:00
pocketbase/core/record_field_resolver.go
2024-09-29 21:09:46 +03:00

364 lines
9.1 KiB
Go

package core
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
// filter modifiers
const (
eachModifier string = "each"
issetModifier string = "isset"
lengthModifier string = "length"
)
// ensure that `search.FieldResolver` interface is implemented
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
// RecordFieldResolver defines a custom search resolver struct for
// managing Record model search fields.
//
// Usually used together with `search.Provider`.
// Example:
//
// resolver := resolvers.NewRecordFieldResolver(
// app,
// myCollection,
// &models.RequestInfo{...},
// true,
// )
// provider := search.NewProvider(resolver)
// ...
type RecordFieldResolver struct {
app App
baseCollection *Collection
requestInfo *RequestInfo
staticRequestInfo map[string]any
allowedFields []string
joins []*join
allowHiddenFields bool
}
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
func NewRecordFieldResolver(
app App,
baseCollection *Collection,
requestInfo *RequestInfo,
allowHiddenFields bool,
) *RecordFieldResolver {
r := &RecordFieldResolver{
app: app,
baseCollection: baseCollection,
requestInfo: requestInfo,
allowHiddenFields: allowHiddenFields, // note: it is not based only on the requestInfo.auth since it could be used by a non-request internal method
joins: []*join{},
allowedFields: []string{
`^\w+[\w\.\:]*$`,
`^\@request\.context$`,
`^\@request\.method$`,
`^\@request\.auth\.[\w\.\:]*\w+$`,
`^\@request\.body\.[\w\.\:]*\w+$`,
`^\@request\.query\.[\w\.\:]*\w+$`,
`^\@request\.headers\.[\w\.\:]*\w+$`,
`^\@collection\.\w+(\:\w+)?\.[\w\.\:]*\w+$`,
},
}
r.staticRequestInfo = map[string]any{}
if r.requestInfo != nil {
r.staticRequestInfo["context"] = r.requestInfo.Context
r.staticRequestInfo["method"] = r.requestInfo.Method
r.staticRequestInfo["query"] = r.requestInfo.Query
r.staticRequestInfo["headers"] = r.requestInfo.Headers
r.staticRequestInfo["body"] = r.requestInfo.Body
r.staticRequestInfo["auth"] = nil
if r.requestInfo.Auth != nil {
authClone := r.requestInfo.Auth.Clone()
r.staticRequestInfo["auth"] = authClone.
Unhide(authClone.Collection().Fields.FieldNames()...).
IgnoreEmailVisibility(true).
PublicExport()
}
}
return r
}
// UpdateQuery implements `search.FieldResolver` interface.
//
// Conditionally updates the provided search query based on the
// resolved fields (eg. dynamically joining relations).
func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
if len(r.joins) > 0 {
query.Distinct(true)
for _, join := range r.joins {
query.LeftJoin(
(join.tableName + " " + join.tableAlias),
join.on,
)
}
}
return nil
}
// Resolve implements `search.FieldResolver` interface.
//
// Example of some resolvable fieldName formats:
//
// id
// someSelect.each
// project.screen.status
// screen.project_via_prototype.name
// @request.context
// @request.method
// @request.query.filter
// @request.headers.x_token
// @request.auth.someRelation.name
// @request.body.someRelation.name
// @request.body.someField
// @request.body.someSelect:each
// @request.body.someField:isset
// @collection.product.name
func (r *RecordFieldResolver) Resolve(fieldName string) (*search.ResolverResult, error) {
return parseAndRun(fieldName, r)
}
func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search.ResolverResult, error) {
if len(path) == 0 {
return nil, errors.New("at least one path key should be provided")
}
lastProp, modifier, err := splitModifier(path[len(path)-1])
if err != nil {
return nil, err
}
path[len(path)-1] = lastProp
// extract value
resultVal, err := extractNestedVal(r.staticRequestInfo, path...)
if err != nil {
r.app.Logger().Debug("resolveStaticRequestField graceful fallback", "error", err.Error())
}
if modifier == issetModifier {
if err != nil {
return &search.ResolverResult{Identifier: "FALSE"}, nil
}
return &search.ResolverResult{Identifier: "TRUE"}, nil
}
// note: we are ignoring the error because requestInfo is dynamic
// and some of the lookup keys may not be defined for the request
switch v := resultVal.(type) {
case nil:
return &search.ResolverResult{Identifier: "NULL"}, nil
case string:
// check if it is a number field and explicitly try to cast to
// float in case of a numeric string value was used
// (this usually the case when the data is from a multipart/form-data request)
field := r.baseCollection.Fields.GetByName(path[len(path)-1])
if field != nil && field.Type() == FieldTypeNumber {
if nv, err := strconv.ParseFloat(v, 64); err == nil {
resultVal = nv
}
}
// otherwise - no further processing is needed...
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
// no further processing is needed...
default:
// non-plain value
// try casting to string (in case for exampe fmt.Stringer is implemented)
val, castErr := cast.ToStringE(v)
// if that doesn't work, try encoding it
if castErr != nil {
encoded, jsonErr := json.Marshal(v)
if jsonErr == nil {
val = string(encoded)
}
}
resultVal = val
}
placeholder := "f" + security.PseudorandomString(6)
return &search.ResolverResult{
Identifier: "{:" + placeholder + "}",
Params: dbx.Params{placeholder: resultVal},
}, nil
}
func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collection, error) {
if collectionNameOrId == r.baseCollection.Name || collectionNameOrId == r.baseCollection.Id {
return r.baseCollection, nil
}
return getCollectionByModelOrIdentifier(r.app, collectionNameOrId)
}
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
join := &join{
tableName: tableName,
tableAlias: tableAlias,
on: on,
}
// replace existing join
for i, j := range r.joins {
if j.tableAlias == join.tableAlias {
r.joins[i] = join
return
}
}
// register new join
r.joins = append(r.joins, join)
}
type mapExtractor interface {
AsMap() map[string]any
}
func extractNestedVal(rawData any, keys ...string) (any, error) {
if len(keys) == 0 {
return nil, errors.New("at least one key should be provided")
}
switch m := rawData.(type) {
// maps
case map[string]any:
return mapVal(m, keys...)
case map[string]string:
return mapVal(m, keys...)
case map[string]bool:
return mapVal(m, keys...)
case map[string]float32:
return mapVal(m, keys...)
case map[string]float64:
return mapVal(m, keys...)
case map[string]int:
return mapVal(m, keys...)
case map[string]int8:
return mapVal(m, keys...)
case map[string]int16:
return mapVal(m, keys...)
case map[string]int32:
return mapVal(m, keys...)
case map[string]int64:
return mapVal(m, keys...)
case map[string]uint:
return mapVal(m, keys...)
case map[string]uint8:
return mapVal(m, keys...)
case map[string]uint16:
return mapVal(m, keys...)
case map[string]uint32:
return mapVal(m, keys...)
case map[string]uint64:
return mapVal(m, keys...)
case mapExtractor:
return mapVal(m.AsMap(), keys...)
// slices
case []string:
return arrVal(m, keys...)
case []bool:
return arrVal(m, keys...)
case []float32:
return arrVal(m, keys...)
case []float64:
return arrVal(m, keys...)
case []int:
return arrVal(m, keys...)
case []int8:
return arrVal(m, keys...)
case []int16:
return arrVal(m, keys...)
case []int32:
return arrVal(m, keys...)
case []int64:
return arrVal(m, keys...)
case []uint:
return arrVal(m, keys...)
case []uint8:
return arrVal(m, keys...)
case []uint16:
return arrVal(m, keys...)
case []uint32:
return arrVal(m, keys...)
case []uint64:
return arrVal(m, keys...)
case []mapExtractor:
extracted := make([]any, len(m))
for i, v := range m {
extracted[i] = v.AsMap()
}
return arrVal(extracted, keys...)
case []any:
return arrVal(m, keys...)
default:
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
}
}
func mapVal[T any](m map[string]T, keys ...string) (any, error) {
result, ok := m[keys[0]]
if !ok {
return nil, fmt.Errorf("invalid key path - missing key %q", keys[0])
}
// end key reached
if len(keys) == 1 {
return result, nil
}
return extractNestedVal(result, keys[1:]...)
}
func arrVal[T any](m []T, keys ...string) (any, error) {
idx, err := strconv.Atoi(keys[0])
if err != nil || idx < 0 || idx >= len(m) {
return nil, fmt.Errorf("invalid key path - invalid or missing array index %q", keys[0])
}
result := m[idx]
// end key reached
if len(keys) == 1 {
return result, nil
}
return extractNestedVal(result, keys[1:]...)
}
func splitModifier(combined string) (string, string, error) {
parts := strings.Split(combined, ":")
if len(parts) != 2 {
return combined, "", nil
}
// validate modifier
switch parts[1] {
case issetModifier,
eachModifier,
lengthModifier:
return parts[0], parts[1], nil
}
return "", "", fmt.Errorf("unknown modifier in %q", combined)
}