1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2024-11-24 17:07:00 +02:00
pocketbase/resolvers/record_field_resolve_runner.go

611 lines
19 KiB
Go

package resolvers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/pocketbase/dbx"
"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"
)
// parseAndRun starts a new one-off RecordFieldResolver.Resolve execution.
func parseAndRun(fieldName string, resolver *RecordFieldResolver) (*search.ResolverResult, error) {
r := &runner{
fieldName: fieldName,
resolver: resolver,
}
return r.run()
}
type runner struct {
used bool // indicates whether the runner was already executed
resolver *RecordFieldResolver // resolver is the shared expression fields resolver
fieldName string // the name of the single field expression the runner is responsible for
// shared processing state
// ---------------------------------------------------------------
activeProps []string // holds the active props that remains to be processed
activeCollectionName string // the last used collection name
activeTableAlias string // the last used table alias
allowHiddenFields bool // indicates whether hidden fields (eg. email) should be allowed without extra conditions
nullifyMisingField bool // indicating whether to return null on missing field or return an error
withMultiMatch bool // indicates whether to attach a multiMatchSubquery condition to the ResolverResult
multiMatchActiveTableAlias string // the last used multi-match table alias
multiMatch *multiMatchSubquery // the multi-match subquery expression generated from the fieldName
}
func (r *runner) run() (*search.ResolverResult, error) {
if r.used {
return nil, fmt.Errorf("the runner was already used")
}
if len(r.resolver.allowedFields) > 0 && !list.ExistInSliceWithRegex(r.fieldName, r.resolver.allowedFields) {
return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
}
defer func() {
r.used = true
}()
r.prepare()
// check for @collection field (aka. non-relational join)
// must be in the format "@collection.COLLECTION_NAME.FIELD[.FIELD2....]"
if r.activeProps[0] == "@collection" {
return r.processCollectionField()
}
if r.activeProps[0] == "@request" {
if r.resolver.requestInfo == nil {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
if strings.HasPrefix(r.fieldName, "@request.auth.") {
return r.processRequestAuthField()
}
if strings.HasPrefix(r.fieldName, "@request.data.") && len(r.activeProps) > 2 {
name, modifier, err := splitModifier(r.activeProps[2])
if err != nil {
return nil, err
}
dataField := r.resolver.baseCollection.Schema.GetFieldByName(name)
if dataField == nil {
return r.resolver.resolveStaticRequestField(r.activeProps[1:]...)
}
dataField.InitOptions()
// check for data relation field
if dataField.Type == schema.FieldTypeRelation && len(r.activeProps) > 3 {
return r.processRequestInfoRelationField(dataField)
}
// check for select:each field
if modifier == eachModifier && dataField.Type == schema.FieldTypeSelect && len(r.activeProps) == 3 {
return r.processRequestInfoSelectEachModifier(dataField)
}
// check for data arrayble fields ":length" modifier
if modifier == lengthModifier && list.ExistInSlice(dataField.Type, schema.ArraybleFieldTypes()) && len(r.activeProps) == 3 {
return r.processRequestInfoLengthModifier(dataField)
}
}
// some other @request.* static field
return r.resolver.resolveStaticRequestField(r.activeProps[1:]...)
}
// regular field
return r.processActiveProps()
}
func (r *runner) prepare() {
r.activeProps = strings.Split(r.fieldName, ".")
r.activeCollectionName = r.resolver.baseCollection.Name
r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
r.allowHiddenFields = r.resolver.allowHiddenFields
// always allow hidden fields since the @.* filter is a system one
if r.activeProps[0] == "@collection" || r.activeProps[0] == "@request" {
r.allowHiddenFields = true
}
// enable the ignore flag for missing @request.* fields for backward
// compatibility and consistency with all @request.* filter fields and types
r.nullifyMisingField = r.activeProps[0] == "@request"
// prepare a multi-match subquery
r.multiMatch = &multiMatchSubquery{
baseTableAlias: r.activeTableAlias,
params: dbx.Params{},
}
r.multiMatch.fromTableName = inflector.Columnify(r.activeCollectionName)
r.multiMatch.fromTableAlias = "__mm_" + r.activeTableAlias
r.multiMatchActiveTableAlias = r.multiMatch.fromTableAlias
r.withMultiMatch = false
}
func (r *runner) processCollectionField() (*search.ResolverResult, error) {
if len(r.activeProps) < 3 {
return nil, fmt.Errorf("invalid @collection field path in %q", r.fieldName)
}
collection, err := r.resolver.loadCollection(r.activeProps[1])
if err != nil {
return nil, fmt.Errorf("failed to load collection %q from field path %q", r.activeProps[1], r.fieldName)
}
r.activeCollectionName = collection.Name
r.activeTableAlias = inflector.Columnify("__collection_" + r.activeCollectionName)
r.withMultiMatch = true
// join the collection to the main query
r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil)
// join the collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm" + r.activeTableAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: inflector.Columnify(collection.Name),
tableAlias: r.multiMatchActiveTableAlias,
})
// leave only the collection fields
// aka. @collection.someCollection.fieldA.fieldB -> fieldA.fieldB
r.activeProps = r.activeProps[2:]
return r.processActiveProps()
}
func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
// plain auth field
// ---
if list.ExistInSlice(r.fieldName, plainRequestAuthFields) {
return r.resolver.resolveStaticRequestField(r.activeProps[1:]...)
}
// resolve the auth collection field
// ---
if r.resolver.requestInfo == nil || r.resolver.requestInfo.AuthRecord == nil || r.resolver.requestInfo.AuthRecord.Collection() == nil {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
collection := r.resolver.requestInfo.AuthRecord.Collection()
r.resolver.loadedCollections = append(r.resolver.loadedCollections, collection)
r.activeCollectionName = collection.Name
r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName)
// join the auth collection to the main query
r.resolver.registerJoin(
inflector.Columnify(r.activeCollectionName),
r.activeTableAlias,
dbx.HashExp{
// aka. __auth_users.id = :userId
(r.activeTableAlias + ".id"): r.resolver.requestInfo.AuthRecord.Id,
},
)
// join the auth collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: inflector.Columnify(r.activeCollectionName),
tableAlias: r.multiMatchActiveTableAlias,
on: dbx.HashExp{
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.AuthRecord.Id,
},
},
)
// leave only the auth relation fields
// aka. @request.auth.fieldA.fieldB -> fieldA.fieldB
r.activeProps = r.activeProps[2:]
return r.processActiveProps()
}
func (r *runner) processRequestInfoLengthModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
result := &search.ResolverResult{
Identifier: fmt.Sprintf("%d", len(dataItems)),
}
return result, nil
}
func (r *runner) processRequestInfoSelectEachModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
options, ok := dataField.Options.(*schema.SelectOptions)
if !ok {
return nil, fmt.Errorf("failed to initialize field %q options", dataField.Name)
}
dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
rawJson, err := json.Marshal(dataItems)
if err != nil {
return nil, fmt.Errorf("cannot marshalize the data select item for field %q", r.activeProps[2])
}
placeholder := "dataSelect" + security.PseudorandomString(4)
cleanFieldName := inflector.Columnify(dataField.Name)
jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
jeAlias := "__dataSelect_" + cleanFieldName + "_je"
r.resolver.registerJoin(jeTable, jeAlias, nil)
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
Params: dbx.Params{placeholder: rawJson},
}
if options.MaxSelect != 1 {
r.withMultiMatch = true
}
if r.withMultiMatch {
placeholder2 := "mm" + placeholder
jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2)
jeAlias2 := "__mm" + jeAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: jeTable2,
tableAlias: jeAlias2,
})
r.multiMatch.params[placeholder2] = rawJson
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
func (r *runner) processRequestInfoRelationField(dataField *schema.SchemaField) (*search.ResolverResult, error) {
options, ok := dataField.Options.(*schema.RelationOptions)
if !ok {
return nil, fmt.Errorf("failed to initialize data field %q options", dataField.Name)
}
dataRelCollection, err := r.resolver.loadCollection(options.CollectionId)
if err != nil {
return nil, fmt.Errorf("failed to load collection %q from data field %q", options.CollectionId, dataField.Name)
}
var dataRelIds []string
if r.resolver.requestInfo != nil && len(r.resolver.requestInfo.Data) != 0 {
dataRelIds = list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
}
if len(dataRelIds) == 0 {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
r.activeCollectionName = dataRelCollection.Name
r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name)
// join the data rel collection to the main collection
r.resolver.registerJoin(
inflector.Columnify(r.activeCollectionName),
r.activeTableAlias,
dbx.In(
fmt.Sprintf("[[%s.id]]", inflector.Columnify(r.activeTableAlias)),
list.ToInterfaceSlice(dataRelIds)...,
),
)
if options.MaxSelect == nil || *options.MaxSelect != 1 {
r.withMultiMatch = true
}
// join the data rel collection to the multi-match subquery
r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name)
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: inflector.Columnify(r.activeCollectionName),
tableAlias: r.multiMatchActiveTableAlias,
on: dbx.In(r.multiMatchActiveTableAlias+".id", list.ToInterfaceSlice(dataRelIds)...),
},
)
// leave only the data relation fields
// aka. @request.data.someRel.fieldA.fieldB -> fieldA.fieldB
r.activeProps = r.activeProps[3:]
return r.processActiveProps()
}
func (r *runner) processActiveProps() (*search.ResolverResult, error) {
totalProps := len(r.activeProps)
for i, prop := range r.activeProps {
collection, err := r.resolver.loadCollection(r.activeCollectionName)
if err != nil {
return nil, fmt.Errorf("failed to resolve field %q", prop)
}
// last prop
if i == totalProps-1 {
// system field, aka. internal model prop
// (always available but not part of the collection schema)
// -------------------------------------------------------
if list.ExistInSlice(prop, resolvableSystemFieldNames(collection)) {
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, inflector.Columnify(prop)),
}
// allow querying only auth records with emails marked as public
if prop == schema.FieldNameEmail && !r.allowHiddenFields {
result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
"[[%s.%s]] = TRUE",
r.activeTableAlias,
schema.FieldNameEmailVisibility,
))))
}
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, inflector.Columnify(prop))
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
name, modifier, err := splitModifier(prop)
if err != nil {
return nil, err
}
field := collection.Schema.GetFieldByName(name)
if field == nil {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("unknown field %q", name)
}
cleanFieldName := inflector.Columnify(field.Name)
// arrayble fields ":length" modifier
// -------------------------------------------------------
if modifier == lengthModifier && list.ExistInSlice(field.Type, schema.ArraybleFieldTypes()) {
jePair := r.activeTableAlias + "." + cleanFieldName
result := &search.ResolverResult{
Identifier: jsonArrayLength(jePair),
}
if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatch.valueIdentifier = jsonArrayLength(jePair2)
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
// select field with ":each" modifier
// -------------------------------------------------------
if field.Type == schema.FieldTypeSelect && modifier == eachModifier {
jePair := r.activeTableAlias + "." + cleanFieldName
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(jsonEach(jePair), jeAlias, nil)
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
}
field.InitOptions()
options, ok := field.Options.(*schema.SelectOptions)
if !ok {
return nil, fmt.Errorf("failed to initialize field %q options", prop)
}
if options.MaxSelect != 1 {
r.withMultiMatch = true
}
if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: jsonEach(jePair2),
tableAlias: jeAlias2,
})
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
// default
// -------------------------------------------------------
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, cleanFieldName),
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, cleanFieldName)
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
field := collection.Schema.GetFieldByName(prop)
if field == nil {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("unknown field %q", prop)
}
// check if it is a json field
if field.Type == schema.FieldTypeJson {
var jsonPath strings.Builder
jsonPath.WriteString("$")
for _, p := range r.activeProps[i+1:] {
if _, err := strconv.Atoi(p); err == nil {
jsonPath.WriteString("[")
jsonPath.WriteString(inflector.Columnify(p))
jsonPath.WriteString("]")
} else {
jsonPath.WriteString(".")
jsonPath.WriteString(inflector.Columnify(p))
}
}
result := &search.ResolverResult{
Identifier: fmt.Sprintf(
"JSON_EXTRACT([[%s.%s]], '%s')",
r.activeTableAlias,
inflector.Columnify(prop),
jsonPath.String(),
),
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = fmt.Sprintf(
"JSON_EXTRACT([[%s.%s]], '%s')",
r.multiMatchActiveTableAlias,
inflector.Columnify(prop),
jsonPath.String(),
)
result.MultiMatchSubQuery = r.multiMatch
}
return result, 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)
}
// join the relation to the main query
// ---
field.InitOptions()
options, ok := field.Options.(*schema.RelationOptions)
if !ok {
return nil, fmt.Errorf("failed to initialize field %q options", prop)
}
relCollection, relErr := r.resolver.loadCollection(options.CollectionId)
if relErr != nil {
return nil, fmt.Errorf("failed to find field %q collection", prop)
}
cleanFieldName := inflector.Columnify(field.Name)
prefixedFieldName := r.activeTableAlias + "." + cleanFieldName
newTableAlias := r.activeTableAlias + "_" + cleanFieldName
newCollectionName := relCollection.Name
if !options.IsMultiple() {
r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)),
)
} else {
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(jsonEach(prefixedFieldName), jeAlias, nil)
r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
)
}
r.activeCollectionName = newCollectionName
r.activeTableAlias = newTableAlias
// ---
// join the relation to the multi-match subquery
// ---
if options.IsMultiple() {
r.withMultiMatch = true
}
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName
prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
if !options.IsMultiple() {
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
},
)
} else {
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: jsonEach(prefixedFieldName2),
tableAlias: jeAlias2,
},
&join{
tableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
},
)
}
r.multiMatchActiveTableAlias = newTableAlias2
// ---
}
return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
}
func jsonArrayLength(tableColumnPair string) string {
return fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_array_length(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE (CASE WHEN [[%s]] = '' OR [[%s]] IS NULL THEN json_array() ELSE json_array([[%s]]) END) END)`,
tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair,
)
}
func jsonEach(tableColumnPair string) string {
return fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END)`,
tableColumnPair, tableColumnPair, tableColumnPair,
)
}
func resolvableSystemFieldNames(collection *models.Collection) []string {
result := schema.BaseModelFieldNames()
if collection.IsAuth() {
result = append(
result,
schema.FieldNameUsername,
schema.FieldNameVerified,
schema.FieldNameEmailVisibility,
schema.FieldNameEmail,
)
}
return result
}