package core import ( "encoding/json" "errors" "fmt" "reflect" "regexp" "strconv" "strings" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/tools/dbutils" "github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/security" ) // maxNestedRels defines the max allowed nested relations depth. const maxNestedRels = 6 // list of auth filter fields that don't require join with the auth // collection or any other extra checks to be resolved. var plainRequestAuthFields = map[string]struct{}{ "@request.auth." + FieldNameId: {}, "@request.auth." + FieldNameCollectionId: {}, "@request.auth." + FieldNameCollectionName: {}, "@request.auth." + FieldNameEmail: {}, "@request.auth." + FieldNameEmailVisibility: {}, "@request.auth." + FieldNameVerified: {}, } // 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, errors.New("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.body.") && len(r.activeProps) > 2 { name, modifier, err := splitModifier(r.activeProps[2]) if err != nil { return nil, err } bodyField := r.resolver.baseCollection.Fields.GetByName(name) if bodyField == nil { return r.resolver.resolveStaticRequestField(r.activeProps[1:]...) } // check for body relation field if bodyField.Type() == FieldTypeRelation && len(r.activeProps) > 3 { return r.processRequestInfoRelationField(bodyField) } // check for body arrayble fields ":each" modifier if modifier == eachModifier && len(r.activeProps) == 3 { return r.processRequestInfoEachModifier(bodyField) } // check for body arrayble fields ":length" modifier if modifier == lengthModifier && len(r.activeProps) == 3 { return r.processRequestInfoLengthModifier(bodyField) } } // 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) } // nameOrId or nameOrId:alias collectionParts := strings.SplitN(r.activeProps[1], ":", 2) collection, err := r.resolver.loadCollection(collectionParts[0]) 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 if len(collectionParts) == 2 && collectionParts[1] != "" { r.activeTableAlias = inflector.Columnify("__collection_alias_" + collectionParts[1]) } else { 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 _, ok := plainRequestAuthFields[r.fieldName]; ok { return r.resolver.resolveStaticRequestField(r.activeProps[1:]...) } // resolve the auth collection field // --- if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil { return &search.ResolverResult{Identifier: "NULL"}, nil } collection := r.resolver.requestInfo.Auth.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.Auth.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.Auth.Id, }, }, ) // leave only the auth relation fields // aka. @request.auth.fieldA.fieldB -> fieldA.fieldB r.activeProps = r.activeProps[2:] return r.processActiveProps() } // note: nil value is returned as empty slice func toSlice(value any) []any { if value == nil { return []any{} } rv := reflect.ValueOf(value) kind := rv.Kind() if kind != reflect.Slice && kind != reflect.Array { return []any{value} } rvLen := rv.Len() result := make([]interface{}, rvLen) for i := 0; i < rvLen; i++ { result[i] = rv.Index(i).Interface() } return result } func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) { if _, ok := bodyField.(MultiValuer); !ok { return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName()) } bodyItems := toSlice(r.resolver.requestInfo.Body[bodyField.GetName()]) result := &search.ResolverResult{ Identifier: strconv.Itoa(len(bodyItems)), } return result, nil } func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.ResolverResult, error) { multiValuer, ok := bodyField.(MultiValuer) if !ok { return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName()) } bodyItems := toSlice(r.resolver.requestInfo.Body[bodyField.GetName()]) bodyItemsRaw, err := json.Marshal(bodyItems) if err != nil { return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2]) } placeholder := "dataEach" + security.PseudorandomString(6) cleanFieldName := inflector.Columnify(bodyField.GetName()) jeTable := fmt.Sprintf("json_each({:%s})", placeholder) jeAlias := "__dataEach_" + cleanFieldName + "_je" r.resolver.registerJoin(jeTable, jeAlias, nil) result := &search.ResolverResult{ Identifier: fmt.Sprintf("[[%s.value]]", jeAlias), Params: dbx.Params{placeholder: bodyItemsRaw}, } if multiValuer.IsMultiple() { 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] = bodyItemsRaw r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2) result.MultiMatchSubQuery = r.multiMatch } return result, nil } func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.ResolverResult, error) { relField, ok := bodyField.(*RelationField) if !ok { return nil, fmt.Errorf("failed to initialize data relation field %q", bodyField.GetName()) } dataRelCollection, err := r.resolver.loadCollection(relField.CollectionId) if err != nil { return nil, fmt.Errorf("failed to load collection %q from data field %q", relField.CollectionId, relField.Name) } var dataRelIds []string if r.resolver.requestInfo != nil && len(r.resolver.requestInfo.Body) != 0 { dataRelIds = list.ToUniqueStringSlice(r.resolver.requestInfo.Body[relField.Name]) } if len(dataRelIds) == 0 { return &search.ResolverResult{Identifier: "NULL"}, nil } r.activeCollectionName = dataRelCollection.Name r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name + "_" + relField.Name) // join the data rel collection to the main collection r.resolver.registerJoin( r.activeCollectionName, r.activeTableAlias, dbx.In( fmt.Sprintf("[[%s.id]]", r.activeTableAlias), list.ToInterfaceSlice(dataRelIds)..., ), ) if relField.IsMultiple() { r.withMultiMatch = true } // join the data rel collection to the multi-match subquery r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name + "_" + relField.Name) r.multiMatch.joins = append( r.multiMatch.joins, &join{ tableName: r.activeCollectionName, tableAlias: r.multiMatchActiveTableAlias, on: dbx.In( fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias), list.ToInterfaceSlice(dataRelIds)..., ), }, ) // leave only the data relation fields // aka. @request.body.someRel.fieldA.fieldB -> fieldA.fieldB r.activeProps = r.activeProps[3:] return r.processActiveProps() } var viaRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`) 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 { return r.processLastProp(collection, prop) } field := collection.Fields.GetByName(prop) // json field -> treat the rest of the props as json path if field != nil && field.Type() == FieldTypeJSON { var jsonPath strings.Builder for j, p := range r.activeProps[i+1:] { if _, err := strconv.Atoi(p); err == nil { jsonPath.WriteString("[") jsonPath.WriteString(inflector.Columnify(p)) jsonPath.WriteString("]") } else { if j > 0 { jsonPath.WriteString(".") } jsonPath.WriteString(inflector.Columnify(p)) } } jsonPathStr := jsonPath.String() result := &search.ResolverResult{ NoCoalesce: true, Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr), } if r.withMultiMatch { r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr) result.MultiMatchSubQuery = r.multiMatch } return result, nil } if i >= maxNestedRels { return nil, fmt.Errorf("max nested relations reached for field %q", prop) } // check for back relation (eg. yourCollection_via_yourRelField) // ----------------------------------------------------------- if field == nil { parts := viaRegex.FindStringSubmatch(prop) if len(parts) != 3 { if r.nullifyMisingField { return &search.ResolverResult{Identifier: "NULL"}, nil } return nil, fmt.Errorf("failed to resolve field %q", prop) } backCollection, err := r.resolver.loadCollection(parts[1]) if err != nil { if r.nullifyMisingField { return &search.ResolverResult{Identifier: "NULL"}, nil } return nil, fmt.Errorf("failed to load back relation field %q collection", prop) } backField := backCollection.Fields.GetByName(parts[2]) if backField == nil { if r.nullifyMisingField { return &search.ResolverResult{Identifier: "NULL"}, nil } return nil, fmt.Errorf("missing back relation field %q", parts[2]) } if backField.Type() != FieldTypeRelation { return nil, fmt.Errorf("invalid back relation field %q", parts[2]) } backRelField, ok := backField.(*RelationField) if !ok { return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName()) } if backRelField.CollectionId != collection.Id { return nil, fmt.Errorf("invalid back relation field %q collection reference", backField.GetName()) } // join the back relation to the main query // --- cleanProp := inflector.Columnify(prop) cleanBackFieldName := inflector.Columnify(backRelField.Name) newTableAlias := r.activeTableAlias + "_" + cleanProp newCollectionName := inflector.Columnify(backCollection.Name) isBackRelMultiple := backRelField.IsMultiple() if !isBackRelMultiple { // additionally check if the rel field has a single column unique index isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backRelField.Name, backCollection.Indexes) } if !isBackRelMultiple { r.resolver.registerJoin( newCollectionName, newTableAlias, dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias, cleanBackFieldName, r.activeTableAlias)), ) } else { jeAlias := r.activeTableAlias + "_" + cleanProp + "_je" r.resolver.registerJoin( newCollectionName, newTableAlias, dbx.NewExp(fmt.Sprintf( "[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})", r.activeTableAlias, jeAlias, dbutils.JSONEach(newTableAlias+"."+cleanBackFieldName), jeAlias, )), ) } r.activeCollectionName = newCollectionName r.activeTableAlias = newTableAlias // --- // join the back relation to the multi-match subquery // --- if isBackRelMultiple { r.withMultiMatch = true // enable multimatch if not already } newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp if !isBackRelMultiple { r.multiMatch.joins = append( r.multiMatch.joins, &join{ tableName: newCollectionName, tableAlias: newTableAlias2, on: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)), }, ) } else { jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + "_je" r.multiMatch.joins = append( r.multiMatch.joins, &join{ tableName: newCollectionName, tableAlias: newTableAlias2, on: dbx.NewExp(fmt.Sprintf( "[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})", r.multiMatchActiveTableAlias, jeAlias2, dbutils.JSONEach(newTableAlias2+"."+cleanBackFieldName), jeAlias2, )), }, ) } r.multiMatchActiveTableAlias = newTableAlias2 // --- continue } // ----------------------------------------------------------- // check for direct relation if field.Type() != FieldTypeRelation { return nil, fmt.Errorf("field %q is not a valid relation", prop) } // join the relation to the main query // --- relField, ok := field.(*RelationField) if !ok { return nil, fmt.Errorf("failed to initialize relation field %q", prop) } relCollection, relErr := r.resolver.loadCollection(relField.CollectionId) if relErr != nil { return nil, fmt.Errorf("failed to load field %q collection", prop) } // "id" lookups optimization for single relations to avoid unnecessary joins, // aka. "user.id" and "user" should produce the same query identifier if !relField.IsMultiple() && // the penultimate prop is "id" i == totalProps-2 && r.activeProps[i+1] == FieldNameId { return r.processLastProp(collection, relField.Name) } cleanFieldName := inflector.Columnify(relField.Name) prefixedFieldName := r.activeTableAlias + "." + cleanFieldName newTableAlias := r.activeTableAlias + "_" + cleanFieldName newCollectionName := relCollection.Name if !relField.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(dbutils.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 relField.IsMultiple() { r.withMultiMatch = true // enable multimatch if not already } newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName if !relField.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: dbutils.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 (r *runner) processLastProp(collection *Collection, prop string) (*search.ResolverResult, error) { name, modifier, err := splitModifier(prop) if err != nil { return nil, err } field := collection.Fields.GetByName(name) if field == nil { if r.nullifyMisingField { return &search.ResolverResult{Identifier: "NULL"}, nil } return nil, fmt.Errorf("unknown field %q", name) } if field.GetHidden() && !r.allowHiddenFields { return nil, fmt.Errorf("non-filterable field %q", name) } multvaluer, isMultivaluer := field.(MultiValuer) cleanFieldName := inflector.Columnify(field.GetName()) // arrayable fields with ":length" modifier // ------------------------------------------------------- if modifier == lengthModifier && isMultivaluer { jePair := r.activeTableAlias + "." + cleanFieldName result := &search.ResolverResult{ Identifier: dbutils.JSONArrayLength(jePair), } if r.withMultiMatch { jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName r.multiMatch.valueIdentifier = dbutils.JSONArrayLength(jePair2) result.MultiMatchSubQuery = r.multiMatch } return result, nil } // arrayable fields with ":each" modifier // ------------------------------------------------------- if modifier == eachModifier && isMultivaluer { jePair := r.activeTableAlias + "." + cleanFieldName jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je" r.resolver.registerJoin(dbutils.JSONEach(jePair), jeAlias, nil) result := &search.ResolverResult{ Identifier: fmt.Sprintf("[[%s.value]]", jeAlias), } if multvaluer.IsMultiple() { r.withMultiMatch = true } if r.withMultiMatch { jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je" r.multiMatch.joins = append(r.multiMatch.joins, &join{ tableName: dbutils.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 } // allow querying only auth records with emails marked as public if field.GetName() == FieldNameEmail && !r.allowHiddenFields && collection.IsAuth() { result.AfterBuild = func(expr dbx.Expression) dbx.Expression { return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf( "[[%s.%s]] = TRUE", r.activeTableAlias, FieldNameEmailVisibility, )))) } } // wrap in json_extract to ensure that top-level primitives // stored as json work correctly when compared to their SQL equivalent // (https://github.com/pocketbase/pocketbase/issues/4068) if field.Type() == FieldTypeJSON { result.NoCoalesce = true result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "") if r.withMultiMatch { r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "") } } return result, nil }