add extra subquery check for client-side relation filtering
This commit is contained in:
parent
d5dcd01551
commit
67ee431585
|
|
@ -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,
|
Method: http.MethodGet,
|
||||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
||||||
ExpectedStatus: 200,
|
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{
|
ExpectedContent: []string{
|
||||||
`"page":1`,
|
`"page":1`,
|
||||||
`"perPage":30`,
|
`"perPage":30`,
|
||||||
|
|
@ -401,9 +423,13 @@ func TestRecordCrudList(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "multi-match - all",
|
Name: "multi-match - all (clients)",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
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,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
`"page":1`,
|
`"page":1`,
|
||||||
|
|
|
||||||
|
|
@ -1195,7 +1195,7 @@ type App interface {
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
// OnMailerSend hook is triggered every time when a new email is
|
// 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.
|
// It allows intercepting the email message or to use a custom mailer client.
|
||||||
OnMailerSend() *hook.Hook[*MailerEvent]
|
OnMailerSend() *hook.Hook[*MailerEvent]
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
|
||||||
// Note: the busy_timeout pragma must be first because
|
// Note: the busy_timeout pragma must be first because
|
||||||
// the connection needs to be set to block on busy before WAL mode
|
// 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.
|
// 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)
|
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ func NewRecordFieldResolver(
|
||||||
return r
|
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.
|
// UpdateQuery implements `search.FieldResolver` interface.
|
||||||
//
|
//
|
||||||
// Conditionally updates the provided search query based on the
|
// 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) {
|
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
|
||||||
join := &join{
|
newJoin := &join{
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
tableAlias: tableAlias,
|
tableAlias: tableAlias,
|
||||||
on: on,
|
on: on,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !r.allowHiddenFields {
|
||||||
|
c, _ := r.loadCollection(tableName)
|
||||||
|
r.updateCollectionJoinWithListRuleSubquery(c, newJoin)
|
||||||
|
}
|
||||||
|
|
||||||
// replace existing join
|
// replace existing join
|
||||||
for i, j := range r.joins {
|
for i, j := range r.joins {
|
||||||
if j.tableAlias == join.tableAlias {
|
if j.tableAlias == newJoin.tableAlias {
|
||||||
r.joins[i] = join
|
r.joins[i] = newJoin
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// register new join
|
// 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 {
|
type mapExtractor interface {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ type runner struct {
|
||||||
activeProps []string // holds the active props that remains to be processed
|
activeProps []string // holds the active props that remains to be processed
|
||||||
activeCollectionName string // the last used collection name
|
activeCollectionName string // the last used collection name
|
||||||
activeTableAlias string // the last used table alias
|
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
|
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
|
withMultiMatch bool // indicates whether to attach a multiMatchSubquery condition to the ResolverResult
|
||||||
multiMatchActiveTableAlias string // the last used multi-match table alias
|
multiMatchActiveTableAlias string // the last used multi-match table alias
|
||||||
|
|
@ -135,12 +134,6 @@ func (r *runner) prepare() {
|
||||||
r.activeCollectionName = r.resolver.baseCollection.Name
|
r.activeCollectionName = r.resolver.baseCollection.Name
|
||||||
r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
|
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
|
// enable the ignore flag for missing @request.* fields for backward
|
||||||
// compatibility and consistency with all @request.* filter fields and types
|
// compatibility and consistency with all @request.* filter fields and types
|
||||||
r.nullifyMisingField = r.activeProps[0] == "@request"
|
r.nullifyMisingField = r.activeProps[0] == "@request"
|
||||||
|
|
@ -416,19 +409,13 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||||
|
|
||||||
field := collection.Fields.GetByName(prop)
|
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)
|
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?
|
// @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
|
// json or geoPoint field -> treat the rest of the props as json path
|
||||||
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
|
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
|
var jsonPath strings.Builder
|
||||||
for j, p := range r.activeProps[i+1:] {
|
for j, p := range r.activeProps[i+1:] {
|
||||||
if _, err := strconv.Atoi(p); err == nil {
|
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])
|
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())
|
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) {
|
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)
|
name, modifier, err := splitModifier(prop)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -703,7 +684,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
|
||||||
return nil, fmt.Errorf("unknown field %q", name)
|
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)
|
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
|
// 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 {
|
result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
|
||||||
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
|
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
|
||||||
"[[%s.%s]] = TRUE",
|
"[[%s.%s]] = TRUE",
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -327,7 +327,6 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||||
if !s.skipTotal {
|
if !s.skipTotal {
|
||||||
// execute the 2 queries concurrently
|
// execute the 2 queries concurrently
|
||||||
errg := new(errgroup.Group)
|
errg := new(errgroup.Group)
|
||||||
errg.SetLimit(2)
|
|
||||||
errg.Go(countExec)
|
errg.Go(countExec)
|
||||||
errg.Go(modelsExec)
|
errg.Go(modelsExec)
|
||||||
if err := errg.Wait(); err != nil {
|
if err := errg.Wait(); err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue