added strftime filter function

This commit is contained in:
Gani Georgiev
2026-01-15 14:27:53 +02:00
parent 8bbd0c2d77
commit 6bf5eccfa7
10 changed files with 726 additions and 159 deletions

View File

@@ -48,7 +48,7 @@ type RecordFieldResolver struct {
requestInfo *RequestInfo
staticRequestInfo map[string]any
allowedFields []string
joins []*join
joins []*search.Join
allowHiddenFields bool
// ---
listRuleJoins map[string]*Collection // tableAlias->collection
@@ -88,7 +88,7 @@ func NewRecordFieldResolver(
baseCollection: baseCollection,
requestInfo: requestInfo,
allowHiddenFields: allowHiddenFields, // note: it is not based only on the requestInfo.auth since it could be used by a non-request internal method
joins: []*join{},
joins: []*search.Join{},
allowedFields: []string{
`^\w+[\w\.\:]*$`,
`^\@request\.context$`,
@@ -133,8 +133,8 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
for _, join := range r.joins {
query.LeftJoin(
(join.tableName + " " + join.tableAlias),
join.on,
(join.TableName + " " + join.TableAlias),
join.On,
)
}
}
@@ -158,11 +158,11 @@ func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, t
}
cloneR := *r
cloneR.joins = []*join{}
cloneR.joins = []*search.Join{}
cloneR.baseCollection = c
cloneR.baseCollectionAlias = tableAlias
cloneR.allowHiddenFields = true
cloneR.joinAliasSuffix = security.PseudorandomString(6)
cloneR.joinAliasSuffix = security.PseudorandomString(8)
expr, err := search.FilterData(*c.ListRule).BuildExpr(&cloneR)
if err != nil {
@@ -176,8 +176,8 @@ func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, t
for _, j := range cloneR.joins {
query.LeftJoin(
(j.tableName + " " + j.tableAlias),
j.on,
(j.TableName + " " + j.TableAlias),
j.On,
)
}
}
@@ -344,7 +344,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
return &search.ResolverResult{Identifier: "NULL"}, nil
}
placeholder := "f" + security.PseudorandomString(8)
placeholder := "f" + security.PseudorandomString(10)
// @todo consider deprecating with the introduction of filter functions
if modifier == lowerModifier {
@@ -369,10 +369,10 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collec
}
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) error {
newJoin := &join{
tableName: tableName,
tableAlias: tableAlias,
on: on,
newJoin := &search.Join{
TableName: tableName,
TableAlias: tableAlias,
On: on,
}
// (see updateQueryWithCollectionListRule)
@@ -389,13 +389,13 @@ func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string,
if r.listRuleJoins == nil {
r.listRuleJoins = map[string]*Collection{}
}
r.listRuleJoins[newJoin.tableAlias] = c
r.listRuleJoins[newJoin.TableAlias] = c
}
}
// replace existing join
for i, j := range r.joins {
if j.tableAlias == newJoin.tableAlias {
if j.TableAlias == newJoin.TableAlias {
r.joins[i] = newJoin
return nil
}

View File

@@ -1,70 +0,0 @@
package core
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"),
)
}

View File

@@ -49,13 +49,13 @@ type runner struct {
// 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
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
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
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 *search.MultiMatchSubquery // the multi-match subquery expression generated from the fieldName
}
func (r *runner) run() (*search.ResolverResult, error) {
@@ -144,13 +144,13 @@ func (r *runner) prepare() {
r.nullifyMisingField = r.activeProps[0] == "@request"
// prepare a multi-match subquery
r.multiMatch = &multiMatchSubquery{
baseTableAlias: r.activeTableAlias,
params: dbx.Params{},
r.multiMatch = &search.MultiMatchSubquery{
TargetTableAlias: r.activeTableAlias,
Params: dbx.Params{},
}
r.multiMatch.fromTableName = inflector.Columnify(r.activeCollectionName)
r.multiMatch.fromTableAlias = "__mm_" + r.activeTableAlias
r.multiMatchActiveTableAlias = r.multiMatch.fromTableAlias
r.multiMatch.FromTableName = inflector.Columnify(r.activeCollectionName)
r.multiMatch.FromTableAlias = "__mm_" + r.activeTableAlias
r.multiMatchActiveTableAlias = r.multiMatch.FromTableAlias
r.withMultiMatch = false
}
@@ -185,9 +185,9 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
// 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,
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
TableName: inflector.Columnify(collection.Name),
TableAlias: r.multiMatchActiveTableAlias,
})
// leave only the collection fields
@@ -230,12 +230,12 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
// 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.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: inflector.Columnify(r.activeCollectionName),
TableAlias: r.multiMatchActiveTableAlias,
On: dbx.HashExp{
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
},
},
@@ -282,7 +282,7 @@ func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.Res
return nil, err
}
placeholder := "@changed@" + name + security.PseudorandomString(6)
placeholder := "@changed@" + name + security.PseudorandomString(8)
result := &search.ResolverResult{
Identifier: placeholder,
@@ -302,7 +302,7 @@ func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.Res
func (r *runner) processRequestBodyLowerModifier(bodyField Field) (*search.ResolverResult, error) {
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(6)
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(8)
result := &search.ResolverResult{
Identifier: "LOWER({:" + placeholder + "})",
@@ -338,7 +338,7 @@ func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.Resolv
return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2])
}
placeholder := "dataEach" + security.PseudorandomString(6)
placeholder := "dataEach" + security.PseudorandomString(8)
cleanFieldName := inflector.Columnify(bodyField.GetName())
jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
jeAlias := "__dataEach_je_" + cleanFieldName + r.resolver.joinAliasSuffix
@@ -362,12 +362,12 @@ func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.Resolv
jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2)
jeAlias2 := "__mm_" + jeAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: jeTable2,
tableAlias: jeAlias2,
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
TableName: jeTable2,
TableAlias: jeAlias2,
})
r.multiMatch.params[placeholder2] = bodyItemsRaw
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
r.multiMatch.Params[placeholder2] = bodyItemsRaw
r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -416,12 +416,12 @@ func (r *runner) processRequestBodyRelationField(bodyField Field) (*search.Resol
// join the data rel collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: r.activeCollectionName,
tableAlias: r.multiMatchActiveTableAlias,
on: dbx.In(
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: r.activeCollectionName,
TableAlias: r.multiMatchActiveTableAlias,
On: dbx.In(
fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias),
list.ToInterfaceSlice(dataRelIds)...,
),
@@ -482,7 +482,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -598,22 +598,22 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
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)),
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: newCollectionName,
TableAlias: newTableAlias2,
On: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
},
)
} else {
jeAlias2 := "__je_" + newTableAlias2
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: newCollectionName,
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf(
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: newCollectionName,
TableAlias: newTableAlias2,
On: dbx.NewExp(fmt.Sprintf(
"[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
r.multiMatchActiveTableAlias,
jeAlias2,
@@ -702,26 +702,26 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
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)),
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.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,
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.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)),
&search.Join{
TableName: inflector.Columnify(newCollectionName),
TableAlias: newTableAlias2,
On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
},
)
}
@@ -766,7 +766,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatch.valueIdentifier = dbutils.JSONArrayLength(jePair2)
r.multiMatch.ValueIdentifier = dbutils.JSONArrayLength(jePair2)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -796,11 +796,11 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
jeAlias2 := "__je_" + r.multiMatchActiveTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: dbutils.JSONEach(jePair2),
tableAlias: jeAlias2,
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
TableName: dbutils.JSONEach(jePair2),
TableAlias: jeAlias2,
})
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -815,7 +815,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
r.multiMatch.ValueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
result.MultiMatchSubQuery = r.multiMatch
}
@@ -837,7 +837,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
result.NoCoalesce = true
result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "")
if r.withMultiMatch {
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
}
}
@@ -845,7 +845,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
if modifier == lowerModifier {
result.Identifier = "LOWER(" + result.Identifier + ")"
if r.withMultiMatch {
r.multiMatch.valueIdentifier = "LOWER(" + r.multiMatch.valueIdentifier + ")"
r.multiMatch.ValueIdentifier = "LOWER(" + r.multiMatch.ValueIdentifier + ")"
}
}

View File

@@ -644,6 +644,27 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
false,
"SELECT `view1`.* FROM `view1` WHERE (([[view1.point]] = '' OR [[view1.point]] IS NULL) OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lat') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lat') END) > {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lon') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lon') END) < {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.something') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.something') END) > {:TEST})",
},
{
"strftime with fixed string as time-value",
"demo5",
"strftime('%Y-%m', '2026-01-01') = true",
false,
"SELECT `demo5`.* FROM `demo5` WHERE strftime({:TEST},{:TEST}) = 1",
},
{
"strftime without multi-match",
"demo5",
"strftime('%Y-%m', rel_one.created) = true",
false,
"SELECT `demo5`.* FROM `demo5` LEFT JOIN `demo4` `demo5_rel_one` ON [[demo5_rel_one.id]] = [[demo5.rel_one]] WHERE strftime({:TEST},[[demo5_rel_one.created]]) = 1 GROUP BY `demo5`.`id`",
},
{
"strftime with multi-match",
"demo5",
"strftime('%Y-%m', rel_many.created) = true",
false,
"SELECT `demo5`.* FROM `demo5` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo5.rel_many]]), json_type([[demo5.rel_many]])='array', FALSE) THEN [[demo5.rel_many]] ELSE json_array([[demo5.rel_many]]) END) `__je_demo5_rel_many` LEFT JOIN `demo4` `demo5_rel_many` ON [[demo5_rel_many.id]] = [[__je_demo5_rel_many.value]] WHERE (((strftime({:TEST},[[demo5_rel_many.created]]) = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT strftime({:TEST},[[__mm_demo5_rel_many.created]]) as [[multiMatchValue]] FROM `demo5` `__mm_demo5` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo5.rel_many]]), json_type([[__mm_demo5.rel_many]])='array', FALSE) THEN [[__mm_demo5.rel_many]] ELSE json_array([[__mm_demo5.rel_many]]) END) `__mm_demo5_rel_many_je` LEFT JOIN `demo4` `__mm_demo5_rel_many` ON [[__mm_demo5_rel_many.id]] = [[__mm_demo5_rel_many_je.value]] WHERE `__mm_demo5`.`id` = `demo5`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1))))) GROUP BY `demo5`.`id`",
},
}
for _, s := range scenarios {