1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-12-03 09:45:19 +02:00

initial public commit

This commit is contained in:
Gani Georgiev
2022-07-07 00:19:05 +03:00
commit 3d07f0211d
484 changed files with 92412 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
package resolvers
import (
"encoding/json"
"fmt"
"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/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
// ensure that `search.FieldResolver` interface is implemented
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
type join struct {
table string
on dbx.Expression
}
// RecordFieldResolver defines a custom search resolver struct for
// managing Record model search fields.
//
// Usually used together with `search.Provider`. Example:
// resolver := resolvers.NewRecordFieldResolver(app.Dao(), myCollection, map[string]any{"test": 123})
// provider := search.NewProvider(resolver)
// ...
type RecordFieldResolver struct {
dao *daos.Dao
baseCollection *models.Collection
allowedFields []string
requestData map[string]any
joins map[string]join
loadedCollections []*models.Collection
}
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
func NewRecordFieldResolver(
dao *daos.Dao,
baseCollection *models.Collection,
requestData map[string]any,
) *RecordFieldResolver {
return &RecordFieldResolver{
dao: dao,
baseCollection: baseCollection,
requestData: requestData,
joins: make(map[string]join),
loadedCollections: []*models.Collection{baseCollection},
allowedFields: []string{
`^\w+[\w\.]*$`,
`^\@request\.method$`,
`^\@request\.user\.\w+[\w\.]*$`,
`^\@request\.data\.\w+[\w\.]*$`,
`^\@request\.query\.\w+[\w\.]*$`,
`^\@collection\.\w+\.\w+[\w\.]*$`,
},
}
}
// 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.table, join.on)
}
}
return nil
}
// Resolve implements `search.FieldResolver` interface.
//
// Example of resolvable field formats:
// id
// project.screen.status
// @request.status
// @collection.product.name
func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, placeholderParams dbx.Params, err error) {
if len(r.allowedFields) > 0 && !list.ExistInSliceWithRegex(fieldName, r.allowedFields) {
return "", nil, fmt.Errorf("Failed to resolve field %q", 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
currentTableAlias := currentCollectionName
// check for @collection field (aka. non-relational join)
// must be in the format "@collection.COLLECTION_NAME.FIELD[.FIELD2....]"
if props[0] == "@collection" {
if len(props) < 3 {
return "", nil, fmt.Errorf("Invalid @collection field path in %q.", fieldName)
}
currentCollectionName = props[1]
currentTableAlias = "c_" + currentCollectionName
collection, err := r.loadCollection(currentCollectionName)
if err != nil {
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
}
r.addJoin(collection.Name, currentTableAlias, "", "", "")
props = props[2:] // leave only the collection fields
}
baseModelFields := schema.ReservedFieldNames()
totalProps := len(props)
for i, prop := range props {
collection, err := r.loadCollection(currentCollectionName)
if err != nil {
return "", nil, fmt.Errorf("Failed to resolve field %q.", prop)
}
// base model prop (always available but not part of the collection schema)
if list.ExistInSlice(prop, baseModelFields) {
return fmt.Sprintf("[[%s.%s]]", inflector.Columnify(currentTableAlias), inflector.Columnify(prop)), nil, nil
}
field := collection.Schema.GetFieldByName(prop)
if field == nil {
return "", nil, fmt.Errorf("Unrecognized field %q.", prop)
}
// last prop
if i == totalProps-1 {
return fmt.Sprintf("[[%s.%s]]", inflector.Columnify(currentTableAlias), inflector.Columnify(prop)), nil, nil
}
// check if it is a relation field
if field.Type != schema.FieldTypeRelation {
return "", nil, fmt.Errorf("Field %q is not a valid relation.", prop)
}
// auto join the relation
// ---
field.InitOptions()
options, ok := field.Options.(*schema.RelationOptions)
if !ok {
return "", nil, fmt.Errorf("Failed to initialize field %q options.", prop)
}
relCollection, relErr := r.loadCollection(options.CollectionId)
if relErr != nil {
return "", nil, fmt.Errorf("Failed to find field %q collection.", prop)
}
newCollectionName := relCollection.Name
newTableAlias := (currentTableAlias + "_" + field.Name)
r.addJoin(
newCollectionName,
newTableAlias,
"id",
currentTableAlias,
field.Name,
)
currentCollectionName = newCollectionName
currentTableAlias = newTableAlias
}
return "", nil, fmt.Errorf("Failed to resolve field %q.", fieldName)
}
func (r *RecordFieldResolver) resolveRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
// ignore error because requestData is dynamic and some of the
// lookup keys may not be defined for the request
resultVal, _ := extractNestedMapVal(r.requestData, path...)
switch v := resultVal.(type) {
case nil:
return "NULL", nil, nil
case string, 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.RandomString(7)
name := fmt.Sprintf("{:%s}", placeholder)
params := dbx.Params{placeholder: resultVal}
return name, params, nil
}
func extractNestedMapVal(m map[string]any, keys ...string) (result any, err error) {
var ok bool
if len(keys) == 0 {
return nil, fmt.Errorf("At least one key should be provided.")
}
if result, ok = m[keys[0]]; !ok {
return nil, fmt.Errorf("Invalid key path - missing key %q.", keys[0])
}
// end key reached
if len(keys) == 1 {
return result, nil
}
if m, ok = result.(map[string]any); !ok {
return nil, fmt.Errorf("Expected map structure, got %#v.", result)
}
return extractNestedMapVal(m, keys[1:]...)
}
func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models.Collection, error) {
// return already loaded
for _, collection := range r.loadedCollections {
if collection.Name == collectionNameOrId || collection.Id == collectionNameOrId {
return collection, nil
}
}
// load collection
collection, err := r.dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
r.loadedCollections = append(r.loadedCollections, collection)
return collection, nil
}
func (r *RecordFieldResolver) addJoin(tableName, tableAlias, fieldName, ref, refFieldName string) {
table := fmt.Sprintf(
"%s %s",
inflector.Columnify(tableName),
inflector.Columnify(tableAlias),
)
var on dbx.Expression
if ref != "" {
on = dbx.NewExp(fmt.Sprintf(
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
inflector.Columnify(ref),
inflector.Columnify(refFieldName),
inflector.Columnify(tableAlias),
inflector.Columnify(fieldName),
))
}
r.joins[tableAlias] = join{table, on}
}

View File

@@ -0,0 +1,245 @@
package resolvers_test
import (
"encoding/json"
"strings"
"testing"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
fieldName string
expectQueryParts []string // we are matching parts of the query
// since joins are added with map iteration and the order is not guaranteed
}{
// missing field
{"", []string{
"SELECT `demo4`.* FROM `demo4`",
}},
// non relation field
{"title", []string{
"SELECT `demo4`.* FROM `demo4`",
}},
// incomplete rel
{"onerel", []string{
"SELECT `demo4`.* FROM `demo4`",
}},
// single rel
{"onerel.title", []string{
"SELECT DISTINCT `demo4`.* FROM `demo4`",
" LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
}},
// nested incomplete rels
{"manyrels.onerel", []string{
"SELECT DISTINCT `demo4`.* FROM `demo4`",
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
}},
// nested complete rels
{"manyrels.onerel.title", []string{
"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
{"manyrels.onerel.manyrels.onerel.title", []string{
"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]] || '%')",
}},
}
for i, s := range scenarios {
query := app.Dao().RecordQuery(collection)
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
r.Resolve(s.fieldName)
if err := r.UpdateQuery(query); err != nil {
t.Errorf("(%d) UpdateQuery failed with error %v", i, err)
continue
}
rawQuery := query.Build().SQL()
partsLength := 0
for _, part := range s.expectQueryParts {
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)
}
}
}
func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
scenarios := []struct {
fieldName string
expectError bool
expectName string
}{
{"", true, ""},
{" ", true, ""},
{"unknown", true, ""},
{"invalid format", true, ""},
{"id", false, "[[demo4.id]]"},
{"created", false, "[[demo4.created]]"},
{"updated", false, "[[demo4.updated]]"},
{"title", false, "[[demo4.title]]"},
{"title.test", true, ""},
{"manyrels", false, "[[demo4.manyrels]]"},
{"manyrels.", true, ""},
{"manyrels.unknown", true, ""},
{"manyrels.title", false, "[[demo4_manyrels.title]]"},
{"manyrels.onerel.manyrels.onefile", false, "[[demo4_manyrels_onerel_manyrels.onefile]]"},
{"@collect", true, ""},
{"collection.demo4.title", true, ""},
{"@collection", true, ""},
{"@collection.unknown", true, ""},
{"@collection.demo", true, ""},
{"@collection.demo.", true, ""},
{"@collection.demo.title", false, "[[c_demo.title]]"},
{"@collection.demo4.title", false, "[[c_demo4.title]]"},
{"@collection.demo4.id", false, "[[c_demo4.id]]"},
{"@collection.demo4.created", false, "[[c_demo4.created]]"},
{"@collection.demo4.updated", false, "[[c_demo4.updated]]"},
{"@collection.demo4.manyrels.missing", true, ""},
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[c_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
}
for i, s := range scenarios {
name, params, err := r.Resolve(s.fieldName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if name != s.expectName {
t.Errorf("(%d) Expected name %q, got %q", i, s.expectName, name)
}
// params should be empty for non @request fields
if len(params) != 0 {
t.Errorf("(%d) Expected 0 params, got %v", i, params)
}
}
}
func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
requestData := map[string]any{
"method": "get",
"query": map[string]any{
"a": 123,
},
"data": map[string]any{
"b": 456,
"c": map[string]int{"sub": 1},
},
"user": nil,
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
scenarios := []struct {
fieldName string
expectError bool
expectParamValue string // encoded json
}{
{"@request", true, ""},
{"@request.invalid format", true, ""},
{"@request.invalid_format2!", true, ""},
{"@request.missing", true, ""},
{"@request.method", false, `"get"`},
{"@request.query", true, ``},
{"@request.query.a", false, `123`},
{"@request.query.a.missing", false, ``},
{"@request.data", true, ``},
{"@request.data.b", false, `456`},
{"@request.data.b.missing", false, ``},
{"@request.data.c", false, `"{\"sub\":1}"`},
{"@request.user", true, ""},
{"@request.user.id", false, ""},
}
for i, s := range scenarios {
name, params, err := r.Resolve(s.fieldName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
// missing key
// ---
if len(params) == 0 {
if name != "NULL" {
t.Errorf("(%d) Expected 0 placeholder parameters, got %v", i, params)
}
continue
}
// existing key
// ---
if len(params) != 1 {
t.Errorf("(%d) Expected 1 placeholder parameter, got %v", i, params)
continue
}
var paramName string
var paramValue any
for k, v := range params {
paramName = k
paramValue = v
}
if name != ("{:" + paramName + "}") {
t.Errorf("(%d) Expected parameter name %q, got %q", i, paramName, name)
}
encodedParamValue, _ := json.Marshal(paramValue)
if string(encodedParamValue) != s.expectParamValue {
t.Errorf("(%d) Expected params %v, got %v", i, s.expectParamValue, string(encodedParamValue))
}
}
}

2
resolvers/resolvers.go Normal file
View File

@@ -0,0 +1,2 @@
// Package resolvers contains custom search.FieldResolver implementations.
package resolvers