filter enhancements
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ dbx.Expression = (*multiMatchSubquery)(nil)
|
||||
|
||||
// join defines the specification for a single SQL JOIN clause.
|
||||
type join struct {
|
||||
tableName string
|
||||
tableAlias string
|
||||
on dbx.Expression
|
||||
}
|
||||
|
||||
// multiMatchSubquery defines a record multi-match subquery expression.
|
||||
type multiMatchSubquery struct {
|
||||
baseTableAlias string
|
||||
fromTableName string
|
||||
fromTableAlias string
|
||||
valueIdentifier string
|
||||
joins []*join
|
||||
params dbx.Params
|
||||
}
|
||||
|
||||
// Build converts the expression into a SQL fragment.
|
||||
//
|
||||
// Implements [dbx.Expression] interface.
|
||||
func (m *multiMatchSubquery) Build(db *dbx.DB, params dbx.Params) string {
|
||||
if m.baseTableAlias == "" || m.fromTableName == "" || m.fromTableAlias == "" {
|
||||
return "0=1"
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = m.params
|
||||
} else {
|
||||
// merge by updating the parent params
|
||||
for k, v := range m.params {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var mergedJoins strings.Builder
|
||||
for i, j := range m.joins {
|
||||
if i > 0 {
|
||||
mergedJoins.WriteString(" ")
|
||||
}
|
||||
mergedJoins.WriteString("LEFT JOIN ")
|
||||
mergedJoins.WriteString(db.QuoteTableName(j.tableName))
|
||||
mergedJoins.WriteString(" ")
|
||||
mergedJoins.WriteString(db.QuoteTableName(j.tableAlias))
|
||||
if j.on != nil {
|
||||
mergedJoins.WriteString(" ON ")
|
||||
mergedJoins.WriteString(j.on.Build(db, params))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`SELECT %s as [[multiMatchValue]] FROM %s %s %s WHERE %s = %s`,
|
||||
db.QuoteColumnName(m.valueIdentifier),
|
||||
db.QuoteTableName(m.fromTableName),
|
||||
db.QuoteTableName(m.fromTableAlias),
|
||||
mergedJoins.String(),
|
||||
db.QuoteColumnName(m.fromTableAlias+".id"),
|
||||
db.QuoteColumnName(m.baseTableAlias+".id"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
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.requestData == 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.processRequestDataRelationField(dataField)
|
||||
}
|
||||
|
||||
// check for select:each field
|
||||
if modifier == eachModifier && dataField.Type == schema.FieldTypeSelect && len(r.activeProps) == 3 {
|
||||
return r.processRequestDataSelectEachModifier(dataField)
|
||||
}
|
||||
|
||||
// check for data arrayble fields ":length" modifier
|
||||
if modifier == lengthModifier && list.ExistInSlice(dataField.Type, schema.ArraybleFieldTypes()) && len(r.activeProps) == 3 {
|
||||
return r.processRequestDataLengthModifier(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.requestData == nil || r.resolver.requestData.AuthRecord == nil || r.resolver.requestData.AuthRecord.Collection() == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
collection := r.resolver.requestData.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.requestData.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.requestData.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) processRequestDataLengthModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
dataItems := list.ToUniqueStringSlice(r.resolver.requestData.Data[dataField.Name])
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: fmt.Sprintf("%d", len(dataItems)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestDataSelectEachModifier(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.requestData.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) processRequestDataRelationField(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.requestData != nil && len(r.resolver.requestData.Data) != 0 {
|
||||
dataRelIds = list.ToUniqueStringSlice(r.resolver.requestData.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.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)
|
||||
newCollectionName := relCollection.Name
|
||||
newTableAlias := r.activeTableAlias + "_" + cleanFieldName
|
||||
|
||||
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
|
||||
jePair := r.activeTableAlias + "." + cleanFieldName
|
||||
r.resolver.registerJoin(jsonEach(jePair), 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.MaxSelect == nil || *options.MaxSelect != 1 {
|
||||
r.withMultiMatch = true
|
||||
}
|
||||
|
||||
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName
|
||||
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
|
||||
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
|
||||
r.multiMatchActiveTableAlias = newTableAlias2
|
||||
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: jsonEach(jePair2),
|
||||
tableAlias: jeAlias2,
|
||||
},
|
||||
&join{
|
||||
tableName: inflector.Columnify(newCollectionName),
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
|
||||
},
|
||||
)
|
||||
// ---
|
||||
}
|
||||
|
||||
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 json_array([[%s]]) END)`,
|
||||
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
|
||||
}
|
||||
+129
-284
@@ -10,18 +10,20 @@ import (
|
||||
"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)
|
||||
// filter modifiers
|
||||
const (
|
||||
eachModifier string = "each"
|
||||
issetModifier string = "isset"
|
||||
lengthModifier string = "length"
|
||||
)
|
||||
|
||||
// list of auth filter fields that don't require join with the auth
|
||||
// collection or any other extra checks to be resolved
|
||||
// collection or any other extra checks to be resolved.
|
||||
var plainRequestAuthFields = []string{
|
||||
"@request.auth." + schema.FieldNameId,
|
||||
"@request.auth." + schema.FieldNameCollectionId,
|
||||
@@ -34,32 +36,28 @@ var plainRequestAuthFields = []string{
|
||||
"@request.auth." + schema.FieldNameUpdated,
|
||||
}
|
||||
|
||||
type join struct {
|
||||
id string
|
||||
table string
|
||||
on dbx.Expression
|
||||
}
|
||||
// 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.Dao(),
|
||||
// myCollection,
|
||||
// &models.RequestData{...},
|
||||
// true,
|
||||
// )
|
||||
// provider := search.NewProvider(resolver)
|
||||
// ...
|
||||
// resolver := resolvers.NewRecordFieldResolver(
|
||||
// app.Dao(),
|
||||
// myCollection,
|
||||
// &models.RequestData{...},
|
||||
// true,
|
||||
// )
|
||||
// provider := search.NewProvider(resolver)
|
||||
// ...
|
||||
type RecordFieldResolver struct {
|
||||
dao *daos.Dao
|
||||
baseCollection *models.Collection
|
||||
allowHiddenFields bool
|
||||
allowedFields []string
|
||||
loadedCollections []*models.Collection
|
||||
joins []join // we cannot use a map because the insertion order is not preserved
|
||||
exprs []dbx.Expression
|
||||
joins []*join // we cannot use a map because the insertion order is not preserved
|
||||
requestData *models.RequestData
|
||||
staticRequestData map[string]any
|
||||
}
|
||||
@@ -76,20 +74,18 @@ func NewRecordFieldResolver(
|
||||
baseCollection: baseCollection,
|
||||
requestData: requestData,
|
||||
allowHiddenFields: allowHiddenFields,
|
||||
joins: []join{},
|
||||
exprs: []dbx.Expression{},
|
||||
joins: []*join{},
|
||||
loadedCollections: []*models.Collection{baseCollection},
|
||||
allowedFields: []string{
|
||||
`^\w+[\w\.]*$`,
|
||||
`^\w+[\w\.\:]*$`,
|
||||
`^\@request\.method$`,
|
||||
`^\@request\.auth\.\w+[\w\.]*$`,
|
||||
`^\@request\.data\.\w+[\w\.]*$`,
|
||||
`^\@request\.query\.\w+[\w\.]*$`,
|
||||
`^\@collection\.\w+\.\w+[\w\.]*$`,
|
||||
`^\@request\.auth\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.data\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.query\.[\w\.\:]*\w+$`,
|
||||
`^\@collection\.\w+\.[\w\.\:]*\w+$`,
|
||||
},
|
||||
}
|
||||
|
||||
// @todo remove after IN operator and multi-match filter enhancements
|
||||
r.staticRequestData = map[string]any{}
|
||||
if r.requestData != nil {
|
||||
r.staticRequestData["method"] = r.requestData.Method
|
||||
@@ -115,13 +111,10 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
query.Distinct(true)
|
||||
|
||||
for _, join := range r.joins {
|
||||
query.LeftJoin(join.table, join.on)
|
||||
}
|
||||
}
|
||||
|
||||
for _, expr := range r.exprs {
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
query.LeftJoin(
|
||||
(join.tableName + " " + join.tableAlias),
|
||||
join.on,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,225 +123,63 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
|
||||
// Resolve implements `search.FieldResolver` interface.
|
||||
//
|
||||
// Example of resolvable field formats:
|
||||
// id
|
||||
// project.screen.status
|
||||
// @request.status
|
||||
// @request.auth.someRelation.name
|
||||
// @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, ".")
|
||||
|
||||
currentCollectionName := r.baseCollection.Name
|
||||
currentTableAlias := inflector.Columnify(currentCollectionName)
|
||||
|
||||
// flag indicating whether to return null on missing field or return on an error
|
||||
nullifyMisingField := false
|
||||
|
||||
allowHiddenFields := r.allowHiddenFields
|
||||
|
||||
// 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 = inflector.Columnify("__collection_" + currentCollectionName)
|
||||
|
||||
collection, err := r.loadCollection(currentCollectionName)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
|
||||
}
|
||||
|
||||
// always allow hidden fields since the @collection.* filter is a system one
|
||||
allowHiddenFields = true
|
||||
|
||||
r.registerJoin(inflector.Columnify(collection.Name), currentTableAlias, nil)
|
||||
|
||||
props = props[2:] // leave only the collection fields
|
||||
} else if props[0] == "@request" {
|
||||
if len(props) == 1 {
|
||||
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
|
||||
}
|
||||
|
||||
if r.requestData == nil {
|
||||
return "NULL", nil, nil
|
||||
}
|
||||
|
||||
// plain @request.* field
|
||||
if !strings.HasPrefix(fieldName, "@request.auth.") || list.ExistInSlice(fieldName, plainRequestAuthFields) {
|
||||
return r.resolveStaticRequestField(props[1:]...)
|
||||
}
|
||||
|
||||
// always allow hidden fields since the @request.* filter is a system one
|
||||
allowHiddenFields = true
|
||||
|
||||
// enable the ignore flag for missing @request.auth.* fields
|
||||
// for consistency with @request.data.* and @request.query.*
|
||||
nullifyMisingField = true
|
||||
|
||||
// resolve the auth collection fields
|
||||
// ---
|
||||
if r.requestData == nil || r.requestData.AuthRecord == nil || r.requestData.AuthRecord.Collection() == nil {
|
||||
return "NULL", nil, nil
|
||||
}
|
||||
|
||||
collection := r.requestData.AuthRecord.Collection()
|
||||
r.loadedCollections = append(r.loadedCollections, collection)
|
||||
|
||||
currentCollectionName = collection.Name
|
||||
currentTableAlias = "__auth_" + inflector.Columnify(currentCollectionName)
|
||||
|
||||
authIdParamKey := "auth" + security.PseudorandomString(5)
|
||||
authIdParams := dbx.Params{authIdParamKey: r.requestData.AuthRecord.Id}
|
||||
// ---
|
||||
|
||||
// join the auth collection
|
||||
r.registerJoin(
|
||||
inflector.Columnify(collection.Name),
|
||||
currentTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf(
|
||||
// aka. __auth_users.id = :userId
|
||||
"[[%s.id]] = {:%s}",
|
||||
inflector.Columnify(currentTableAlias),
|
||||
authIdParamKey,
|
||||
), authIdParams),
|
||||
)
|
||||
|
||||
props = props[2:] // leave only the auth relation fields
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
systemFieldNames := schema.BaseModelFieldNames()
|
||||
if collection.IsAuth() {
|
||||
systemFieldNames = append(
|
||||
systemFieldNames,
|
||||
schema.FieldNameUsername,
|
||||
schema.FieldNameVerified,
|
||||
schema.FieldNameEmailVisibility,
|
||||
schema.FieldNameEmail,
|
||||
)
|
||||
}
|
||||
|
||||
// internal model prop (always available but not part of the collection schema)
|
||||
if list.ExistInSlice(prop, systemFieldNames) {
|
||||
// allow querying only auth records with emails marked as public
|
||||
if prop == schema.FieldNameEmail && !allowHiddenFields {
|
||||
r.registerExpr(dbx.NewExp(fmt.Sprintf(
|
||||
"[[%s.%s]] = TRUE",
|
||||
currentTableAlias,
|
||||
inflector.Columnify(schema.FieldNameEmailVisibility),
|
||||
)))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("[[%s.%s]]", currentTableAlias, inflector.Columnify(prop)), nil, nil
|
||||
}
|
||||
|
||||
field := collection.Schema.GetFieldByName(prop)
|
||||
if field == nil {
|
||||
if nullifyMisingField {
|
||||
return "NULL", nil, nil
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("Unrecognized field %q.", prop)
|
||||
}
|
||||
|
||||
// last prop
|
||||
if i == totalProps-1 {
|
||||
return fmt.Sprintf("[[%s.%s]]", currentTableAlias, inflector.Columnify(prop)), nil, nil
|
||||
}
|
||||
|
||||
// check if it is a json field
|
||||
if field.Type == schema.FieldTypeJson {
|
||||
var jsonPath strings.Builder
|
||||
jsonPath.WriteString("$")
|
||||
for _, p := range props[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))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"JSON_EXTRACT([[%s.%s]], '%s')",
|
||||
currentTableAlias,
|
||||
inflector.Columnify(prop),
|
||||
jsonPath.String(),
|
||||
), 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)
|
||||
}
|
||||
|
||||
cleanFieldName := inflector.Columnify(field.Name)
|
||||
newCollectionName := relCollection.Name
|
||||
newTableAlias := currentTableAlias + "_" + cleanFieldName
|
||||
|
||||
jeTable := currentTableAlias + "_" + cleanFieldName + "_je"
|
||||
jePair := currentTableAlias + "." + cleanFieldName
|
||||
|
||||
r.registerJoin(
|
||||
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)`,
|
||||
jePair, jePair, jePair,
|
||||
),
|
||||
jeTable,
|
||||
nil,
|
||||
)
|
||||
r.registerJoin(
|
||||
inflector.Columnify(newCollectionName),
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeTable)),
|
||||
)
|
||||
|
||||
currentCollectionName = newCollectionName
|
||||
currentTableAlias = newTableAlias
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("Failed to resolve field %q.", fieldName)
|
||||
// Example of some resolvable fieldName formats:
|
||||
//
|
||||
// id
|
||||
// someSelect.each
|
||||
// project.screen.status
|
||||
// @request.status
|
||||
// @request.query.filter
|
||||
// @request.auth.someRelation.name
|
||||
// @request.data.someRelation.name
|
||||
// @request.data.someField
|
||||
// @request.data.someSelect:each
|
||||
// @request.data.someField:isset
|
||||
// @collection.product.name
|
||||
func (r *RecordFieldResolver) Resolve(fieldName string) (*search.ResolverResult, error) {
|
||||
return parseAndRun(fieldName, r)
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) resolveStaticRequestField(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.staticRequestData, path...)
|
||||
func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search.ResolverResult, error) {
|
||||
if len(path) == 0 {
|
||||
return nil, fmt.Errorf("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 := extractNestedMapVal(r.staticRequestData, path...)
|
||||
|
||||
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 requestData is dynamic
|
||||
// and some of the lookup keys may not be defined for the request
|
||||
|
||||
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:
|
||||
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.Schema.GetFieldByName(path[len(path)-1])
|
||||
if field != nil && field.Type == schema.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
|
||||
@@ -367,33 +198,11 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultN
|
||||
}
|
||||
|
||||
placeholder := "f" + security.PseudorandomString(5)
|
||||
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:]...)
|
||||
return &search.ResolverResult{
|
||||
Identifier: "{:" + placeholder + "}",
|
||||
Params: dbx.Params{placeholder: resultVal},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models.Collection, error) {
|
||||
@@ -415,17 +224,15 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
|
||||
tableExpr := (tableName + " " + tableAlias)
|
||||
|
||||
join := join{
|
||||
id: tableAlias,
|
||||
table: tableExpr,
|
||||
on: on,
|
||||
join := &join{
|
||||
tableName: tableName,
|
||||
tableAlias: tableAlias,
|
||||
on: on,
|
||||
}
|
||||
|
||||
// replace existing join
|
||||
for i, j := range r.joins {
|
||||
if j.id == join.id {
|
||||
if j.tableAlias == join.tableAlias {
|
||||
r.joins[i] = join
|
||||
return
|
||||
}
|
||||
@@ -435,6 +242,44 @@ func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string,
|
||||
r.joins = append(r.joins, join)
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) registerExpr(expr dbx.Expression) {
|
||||
r.exprs = append(r.exprs, expr)
|
||||
func extractNestedMapVal(m map[string]any, keys ...string) (any, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, fmt.Errorf("at least one key should be provided")
|
||||
}
|
||||
|
||||
var result any
|
||||
var ok bool
|
||||
|
||||
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, got %#v", result)
|
||||
}
|
||||
|
||||
return extractNestedMapVal(m, 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)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user