add extra subquery check for client-side relation filtering

This commit is contained in:
Gani Georgiev 2025-10-25 13:45:46 +03:00
parent d5dcd01551
commit 67ee431585
7 changed files with 259 additions and 110 deletions

View File

@ -382,10 +382,32 @@ func TestRecordCrudList(t *testing.T) {
},
},
{
Name: "multi-match - at least one of",
Name: "multi-match - at least one of (guest - non-satisfied relation filter API rule)",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 0,
},
},
{
Name: "multi-match - at least one of (clients)",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
@ -401,9 +423,13 @@ func TestRecordCrudList(t *testing.T) {
},
},
{
Name: "multi-match - all",
Name: "multi-match - all (clients)",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,

View File

@ -1195,7 +1195,7 @@ type App interface {
// ---------------------------------------------------------------
// OnMailerSend hook is triggered every time when a new email is
// being send using the [App.NewMailClient()] instance.
// being sent using the [App.NewMailClient()] instance.
//
// It allows intercepting the email message or to use a custom mailer client.
OnMailerSend() *hook.Hook[*MailerEvent]

View File

@ -11,7 +11,7 @@ func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
// Note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-64000)"
db, err := dbx.Open("sqlite", dbPath+pragmas)
if err != nil {

View File

@ -115,6 +115,9 @@ func NewRecordFieldResolver(
return r
}
// @todo consider removing error return type OR update the existin calls to check the error
// @todo think of a better a way how to call it automatically after BuildExpr
//
// UpdateQuery implements `search.FieldResolver` interface.
//
// Conditionally updates the provided search query based on the
@ -240,22 +243,80 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collec
}
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
join := &join{
newJoin := &join{
tableName: tableName,
tableAlias: tableAlias,
on: on,
}
if !r.allowHiddenFields {
c, _ := r.loadCollection(tableName)
r.updateCollectionJoinWithListRuleSubquery(c, newJoin)
}
// replace existing join
for i, j := range r.joins {
if j.tableAlias == join.tableAlias {
r.joins[i] = join
if j.tableAlias == newJoin.tableAlias {
r.joins[i] = newJoin
return
}
}
// register new join
r.joins = append(r.joins, join)
r.joins = append(r.joins, newJoin)
}
func (r *RecordFieldResolver) updateCollectionJoinWithListRuleSubquery(c *Collection, j *join) {
if c == nil {
return
}
// resolve to empty set for superusers only collections
// (treat all collection fields as "hidden")
if c.ListRule == nil {
if !r.allowHiddenFields {
j.on = dbx.NewExp("1=2")
}
return
}
if *c.ListRule == "" {
return
}
primaryCol := "_rowid_"
if c.IsView() {
primaryCol = "id"
}
subquery := r.app.DB().Select("(1)").From(c.Name)
subquery.AndWhere(dbx.NewExp("[[" + j.tableAlias + "." + primaryCol + "]]=[[" + c.Name + "." + primaryCol + "]]"))
cloneR := *r
cloneR.joins = []*join{}
cloneR.baseCollection = c
expr, err := search.FilterData(*c.ListRule).BuildExpr(&cloneR)
if err != nil {
// just log for now and resolve to empty set to minimize breaking changes
r.app.Logger().Warn("Failed to buld collection join list rule subquery filter expression", "error", err)
j.on = dbx.NewExp("1=2")
return
}
subquery.AndWhere(expr)
err = cloneR.UpdateQuery(subquery)
if err != nil {
// just log for now and resolve to empty set to minimize breaking changes
r.app.Logger().Warn("Failed to update collection join with list rule subquery", "error", err)
j.on = dbx.NewExp("1=2")
return
}
sb := subquery.Build()
j.on = dbx.And(j.on, dbx.NewExp("EXISTS ("+sb.SQL()+")", sb.Params()))
}
type mapExtractor interface {

View File

@ -52,7 +52,6 @@ type runner struct {
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
@ -135,12 +134,6 @@ func (r *runner) prepare() {
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"
@ -416,19 +409,13 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
field := collection.Fields.GetByName(prop)
if field != nil && field.GetHidden() && !r.allowHiddenFields {
if field != nil && field.GetHidden() && !r.resolver.allowHiddenFields {
return nil, fmt.Errorf("non-filterable field %q", prop)
}
// @todo consider moving to the finalizer and converting to "JSONExtractable" interface with optional extra validation for the remaining props?
// json or geoPoint field -> treat the rest of the props as json path
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
// consider List/Search superusers-only collections as if all their fields are hidden
// (apply only for the last nested filter field for now to minimize breaking changes)
if i > 0 && collection.ListRule == nil && !r.allowHiddenFields {
return nil, fmt.Errorf("collection %q is not allowed to be filtered", collection.Name)
}
var jsonPath strings.Builder
for j, p := range r.activeProps[i+1:] {
if _, err := strconv.Atoi(p); err == nil {
@ -495,7 +482,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
}
if backField.GetHidden() && !r.allowHiddenFields {
if backField.GetHidden() && !r.resolver.allowHiddenFields {
return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
}
@ -684,12 +671,6 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop string, propDepth int) (*search.ResolverResult, error) {
// consider List/Search superusers-only collections as if all their fields are hidden
// (apply only for the last nested filter field for now to minimize breaking changes)
if propDepth > 0 && collection.ListRule == nil && !r.allowHiddenFields {
return nil, fmt.Errorf("collection %q is not allowed to be filtered", collection.Name)
}
name, modifier, err := splitModifier(prop)
if err != nil {
return nil, err
@ -703,7 +684,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
return nil, fmt.Errorf("unknown field %q", name)
}
if field.GetHidden() && !r.allowHiddenFields {
if field.GetHidden() && !r.resolver.allowHiddenFields {
return nil, fmt.Errorf("non-filterable field %q", name)
}
@ -772,7 +753,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
}
// allow querying only auth records with emails marked as public
if field.GetName() == FieldNameEmail && !r.allowHiddenFields && collection.IsAuth() {
if field.GetName() == FieldNameEmail && !r.resolver.allowHiddenFields && collection.IsAuth() {
result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
"[[%s.%s]] = TRUE",

File diff suppressed because one or more lines are too long

View File

@ -327,7 +327,6 @@ func (s *Provider) Exec(items any) (*Result, error) {
if !s.skipTotal {
// execute the 2 queries concurrently
errg := new(errgroup.Group)
errg.SetLimit(2)
errg.Go(countExec)
errg.Go(modelsExec)
if err := errg.Wait(); err != nil {