Skip to content

Commit 85bae90

Browse files
Dragomir-Ivanovsmyrman
authored andcommitted
Implement $elemMatch query op.
This patch allows using `filter={field:{$elemMatch:{subfield:"foo"}}}` or `filter={field:{$elemMatch:{subfield:{$regex:"^foo"}}}}` where `field` is an array containing an object, and `subfield` is object field. Also make `rest-layer-mem` match Mongo behaviour in this usecase.
1 parent 0bfe022 commit 85bae90

File tree

6 files changed

+399
-13
lines changed

6 files changed

+399
-13
lines changed

README.md

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -919,20 +919,46 @@ The same example with flags:
919919
However, keep in mind that Storers have to support regular expression and depending on the implementation of the storage handler the accepted syntax may vary.
920920
An error of `ErrNotImplemented` will be returned for those storage backends which do not support the `$regex` operator.
921921
922+
The `$elemMatch` operator matches documents that contain an array field with at least one element that matches all the specified query criteria.
923+
```go
924+
"telephones": schema.Field{
925+
Filterable: true,
926+
Validator: &schema.Array{
927+
Values: schema.Field{
928+
Validator: &schema.Object{Schema: &Telephone},
929+
},
930+
},
931+
},
932+
```
933+
934+
Matching documents that contain specific values within array objects can be done with `$elemMatch`:
935+
```js
936+
{telephones: {$elemMatch: {name: "John Snow", active: true}}}
937+
```
938+
The snippet above will return all documents, which `telephones` array field contains objects that have `name` **_AND_** `active` fields matching queried values.
939+
> Note that documents returned may contain other objects in `telephones` that don't match the query above, but at least one object will do. Further filtering could be needed on the API client side.
940+
941+
#### *$elemMatch* Limitation
942+
`$elemMatch` will work only for arrays of objects for now. Later it could be extended to work on plain arrays e.g:
943+
```js
944+
{numbers: {$elemMatch: {$gt: 20}}}
945+
```
946+
922947
#### Filter operators
923948
924-
| Operator | Usage | Description
925-
| --------- | ------------------------------- | ------------
926-
| `$or` | `{$or: [{a: "b"}, {a: "c"}]}` | Join two clauses with a logical `OR` conjunction.
927-
| `$and` | `{$and: [{a: "b"}, {b: "c"}]}` | Join two clauses with a logical `AND` conjunction.
928-
| `$in` | `{a: {$in: ["b", "c"]}}` | Match a field against several values.
929-
| `$nin` | `{a: {$nin: ["b", "c"]}}` | Opposite of `$in`.
930-
| `$lt` | `{a: {$lt: 10}}` | Fields value is lower than specified number.
931-
| `$lte` | `{a: {$lte: 10}}` | Fields value is lower than or equal to the specified number.
932-
| `$gt` | `{a: {$gt: 10}}` | Fields value is greater than specified number.
933-
| `$gte` | `{a: {$gte: 10}}` | Fields value is greater than or equal to the specified number.
934-
| `$exists` | `{a: {$exists: true}}` | Match if the field is present (or not if set to `false`) in the item, event if `nil`.
935-
| `$regex` | `{a: {$regex: "fo[o]{1}"}}` | Match regular expression on a field's value.
949+
| Operator | Usage | Description
950+
| -------------| --------------------------------| ------------
951+
| `$or` | `{$or: [{a: "b"}, {a: "c"}]}` | Join two clauses with a logical `OR` conjunction.
952+
| `$and` | `{$and: [{a: "b"}, {b: "c"}]}` | Join two clauses with a logical `AND` conjunction.
953+
| `$in` | `{a: {$in: ["b", "c"]}}` | Match a field against several values.
954+
| `$nin` | `{a: {$nin: ["b", "c"]}}` | Opposite of `$in`.
955+
| `$lt` | `{a: {$lt: 10}}` | Fields value is lower than specified number.
956+
| `$lte` | `{a: {$lte: 10}}` | Fields value is lower than or equal to the specified number.
957+
| `$gt` | `{a: {$gt: 10}}` | Fields value is greater than specified number.
958+
| `$gte` | `{a: {$gte: 10}}` | Fields value is greater than or equal to the specified number.
959+
| `$exists` | `{a: {$exists: true}}` | Match if the field is present (or not if set to `false`) in the item, event if `nil`.
960+
| `$regex` | `{a: {$regex: "fo[o]{1}"}}` | Match regular expression on a field's value.
961+
| `$elemMatch` | `{a: {$elemMatch: {b: "foo"}}}` | Match array items against multiple query criteria.
936962
937963
*Some storage handlers may not support all operators. Refer to the storage handler's documentation for more info.*
938964

rest/method_get_test.go

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,255 @@ func TestGetListFilter(t *testing.T) {
343343
t.Run(n, tc.Test)
344344
}
345345
}
346+
347+
func TestGetListArray(t *testing.T) {
348+
sharedInit := func() *requestTestVars {
349+
s := mem.NewHandler()
350+
s.Insert(context.TODO(), []*resource.Item{
351+
{ID: "1", Payload: map[string]interface{}{"id": 1,
352+
"foo": []interface{}{
353+
map[string]interface{}{
354+
"a": "bar",
355+
"b": 10,
356+
},
357+
map[string]interface{}{
358+
"a": "bar1",
359+
"b": 101,
360+
},
361+
}},
362+
},
363+
{ID: "2", Payload: map[string]interface{}{"id": 2,
364+
"foo": []interface{}{
365+
map[string]interface{}{
366+
"a": "bar",
367+
"b": 20,
368+
},
369+
}},
370+
},
371+
{ID: "3", Payload: map[string]interface{}{"id": 3,
372+
"foo": []interface{}{
373+
map[string]interface{}{
374+
"a": "baz",
375+
"b": 30,
376+
"c": "true",
377+
},
378+
}},
379+
},
380+
})
381+
382+
arrayObj := &schema.Object{
383+
Schema: &schema.Schema{
384+
Fields: schema.Fields{
385+
"a": {
386+
Filterable: true,
387+
Validator: &schema.String{},
388+
},
389+
"b": {
390+
Filterable: true,
391+
Validator: &schema.Integer{},
392+
},
393+
"c": {
394+
Filterable: true,
395+
Validator: &schema.String{},
396+
},
397+
},
398+
},
399+
}
400+
idx := resource.NewIndex()
401+
idx.Bind("foo", schema.Schema{
402+
Fields: schema.Fields{
403+
"foo": {
404+
Filterable: true,
405+
Validator: &schema.Array{
406+
Values: schema.Field{
407+
Validator: arrayObj,
408+
},
409+
},
410+
},
411+
},
412+
}, s, resource.DefaultConf)
413+
414+
return &requestTestVars{
415+
Index: idx,
416+
Storers: map[string]resource.Storer{"foo": s},
417+
}
418+
}
419+
420+
// NOTE: Having an array of objects, only one object needs to match the predicate
421+
// for the whole record to be returned, including all other objects in that array,
422+
// that may not match predicate given.
423+
tests := map[string]requestTest{
424+
`filter/array:foo.a:not-found`: {
425+
Init: sharedInit,
426+
NewRequest: func() (*http.Request, error) {
427+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{a:"mar"}}}`, nil)
428+
},
429+
ResponseCode: http.StatusOK,
430+
ResponseBody: `[]`,
431+
},
432+
`filter/array:foo.a`: {
433+
Init: sharedInit,
434+
NewRequest: func() (*http.Request, error) {
435+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{a:"bar"}}}`, nil)
436+
},
437+
ResponseCode: http.StatusOK,
438+
ResponseBody: `[
439+
{"id":1,"foo":[
440+
{"a":"bar","b":10},
441+
{"a":"bar1","b":101}
442+
]},
443+
{"id":2,"foo":[{"a":"bar","b":20}]}
444+
]`,
445+
},
446+
`filter/array:foo.a+foo.b`: {
447+
Init: sharedInit,
448+
NewRequest: func() (*http.Request, error) {
449+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{a:"bar",b:10}}}`, nil)
450+
},
451+
ResponseCode: http.StatusOK,
452+
ResponseBody: `[
453+
{"id":1,"foo":[
454+
{"a":"bar","b":10},
455+
{"a":"bar1","b":101}
456+
]}
457+
]`,
458+
},
459+
`filter/array:foo.b`: {
460+
Init: sharedInit,
461+
NewRequest: func() (*http.Request, error) {
462+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{b:10}}}`, nil)
463+
},
464+
ResponseCode: http.StatusOK,
465+
ResponseBody: `[
466+
{"id":1,"foo":[
467+
{"a":"bar","b":10},
468+
{"a":"bar1","b":101}
469+
]}
470+
]`,
471+
},
472+
`filter/array:foo.a:regex`: {
473+
Init: sharedInit,
474+
NewRequest: func() (*http.Request, error) {
475+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{a:{$regex:"az$"}}}}`, nil)
476+
},
477+
ResponseCode: http.StatusOK,
478+
ResponseBody: `[
479+
{"id":3,"foo":[{"a":"baz","b":30,"c":"true"}]}
480+
]`,
481+
},
482+
`filter/array:foo.b:gt`: {
483+
Init: sharedInit,
484+
NewRequest: func() (*http.Request, error) {
485+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{b:{$gt:20}}}}`, nil)
486+
},
487+
ResponseCode: http.StatusOK,
488+
ResponseBody: `[
489+
{"id":1,"foo":[
490+
{"a":"bar","b":10},
491+
{"a":"bar1","b":101}
492+
]},
493+
{"id":3,"foo":[{"a":"baz","b":30,"c":"true"}]}
494+
]`,
495+
},
496+
`filter/array:foo.b:gte`: {
497+
Init: sharedInit,
498+
NewRequest: func() (*http.Request, error) {
499+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{b:{$gte:20}}}}`, nil)
500+
},
501+
ResponseCode: http.StatusOK,
502+
ResponseBody: `[
503+
{"id":1,"foo":[
504+
{"a":"bar","b":10},
505+
{"a":"bar1","b":101}
506+
]},
507+
{"id":2,"foo":[{"a":"bar","b":20}]},
508+
{"id":3,"foo":[{"a":"baz","b":30,"c":"true"}]}
509+
]`,
510+
},
511+
`filter/array:foo.b:lt`: {
512+
Init: sharedInit,
513+
NewRequest: func() (*http.Request, error) {
514+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{b:{$lt:20}}}}`, nil)
515+
},
516+
ResponseCode: http.StatusOK,
517+
ResponseBody: `[
518+
{"id":1,"foo":[
519+
{"a":"bar","b":10},
520+
{"a":"bar1","b":101}
521+
]}
522+
]`,
523+
},
524+
`filter/array:foo.b:lte`: {
525+
Init: sharedInit,
526+
NewRequest: func() (*http.Request, error) {
527+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{b:{$lte:20}}}}`, nil)
528+
},
529+
ResponseCode: http.StatusOK,
530+
ResponseBody: `[
531+
{"id":1,"foo":[
532+
{"a":"bar","b":10},
533+
{"a":"bar1","b":101}
534+
]},
535+
{"id":2,"foo":[{"a":"bar","b":20}]}
536+
]`,
537+
},
538+
`filter/array:foo.b:$in`: {
539+
Init: sharedInit,
540+
NewRequest: func() (*http.Request, error) {
541+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{b:{$in:[10,20]}}}}`, nil)
542+
},
543+
ResponseCode: http.StatusOK,
544+
ResponseBody: `[
545+
{"id":1,"foo":[
546+
{"a":"bar","b":10},
547+
{"a":"bar1","b":101}
548+
]},
549+
{"id":2,"foo":[{"a":"bar","b":20}]}
550+
]`,
551+
},
552+
`filter/array:foo.b:$exists-true`: {
553+
Init: sharedInit,
554+
NewRequest: func() (*http.Request, error) {
555+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{c:{$exists:true}}}}`, nil)
556+
},
557+
ResponseCode: http.StatusOK,
558+
ResponseBody: `[
559+
{"id":3,"foo":[{"a":"baz","b":30,"c":"true"}]}
560+
]`,
561+
},
562+
`filter/array:foo.b:$exists-false`: {
563+
Init: sharedInit,
564+
NewRequest: func() (*http.Request, error) {
565+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:{c:{$exists:false}}}}`, nil)
566+
},
567+
ResponseCode: http.StatusOK,
568+
ResponseBody: `[
569+
{"id":1,"foo":[
570+
{"a":"bar","b":10},
571+
{"a":"bar1","b":101}
572+
]},
573+
{"id":2,"foo":[{"a":"bar","b":20}]}
574+
]`,
575+
},
576+
`filter/array:foo:not-an-array`: {
577+
Init: sharedInit,
578+
NewRequest: func() (*http.Request, error) {
579+
return http.NewRequest("GET", `/foo?filter={foo:"mar"}`, nil)
580+
},
581+
ResponseCode: http.StatusUnprocessableEntity,
582+
ResponseBody: `{"code":422,"issues":{"filter":["foo: invalid query expression: not an array"]},"message":"URL parameters contain error(s)"}`,
583+
},
584+
`filter/array:foo:invalid-elemMatch`: {
585+
Init: sharedInit,
586+
NewRequest: func() (*http.Request, error) {
587+
return http.NewRequest("GET", `/foo?filter={foo:{$elemMatch:"mar"}}`, nil)
588+
},
589+
ResponseCode: http.StatusUnprocessableEntity,
590+
ResponseBody: `{"code":422,"issues":{"filter":["char 17: foo: $elemMatch: expected '{' got '\"'"]},"message":"URL parameters contain error(s)"}`,
591+
},
592+
}
593+
for n, tc := range tests {
594+
tc := tc // capture range variable
595+
t.Run(n, tc.Test)
596+
}
597+
}

schema/query/predicate.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package query
22

33
import (
4+
"fmt"
45
"reflect"
56
"regexp"
67
"strings"
@@ -20,6 +21,7 @@ const (
2021
opGreaterThan = "$gt"
2122
opGreaterOrEqual = "$gte"
2223
opRegex = "$regex"
24+
opElemMatch = "$elemMatch"
2325
)
2426

2527
// Predicate defines an expression against a schema to perform a match on schema's data.
@@ -493,3 +495,60 @@ func (e *Regex) Prepare(validator schema.Validator) error {
493495
func (e Regex) String() string {
494496
return quoteField(e.Field) + ": {" + opRegex + ": " + valueString(e.Value) + "}"
495497
}
498+
499+
// ElemMatch matches object values specified in an array.
500+
type ElemMatch struct {
501+
Field string
502+
Exps []Expression
503+
}
504+
505+
// Match implements Expression interface.
506+
func (e ElemMatch) Match(payload map[string]interface{}) bool {
507+
value := getField(payload, e.Field)
508+
509+
arr, ok := value.([]interface{})
510+
if !ok {
511+
return false
512+
}
513+
514+
p := Predicate(e.Exps)
515+
for _, val := range arr {
516+
if v, ok := val.(map[string]interface{}); ok {
517+
if p.Match(v) {
518+
return true
519+
}
520+
}
521+
}
522+
523+
return false
524+
}
525+
526+
// Prepare implements Expression interface.
527+
func (e *ElemMatch) Prepare(validator schema.Validator) error {
528+
f, err := getValidatorField(e.Field, validator)
529+
if err != nil {
530+
return err
531+
}
532+
533+
arr, ok := f.Validator.(*schema.Array)
534+
if !ok {
535+
return fmt.Errorf("%s: is not an array", e.Field)
536+
}
537+
538+
// FIXME: Should allow any type.
539+
obj, ok := arr.Values.Validator.(*schema.Object)
540+
if !ok {
541+
return fmt.Errorf("%s: array elements are not schema.Object", e.Field)
542+
}
543+
544+
return prepareExpressions(e.Exps, obj.Schema)
545+
}
546+
547+
// String implements Expression interface.
548+
func (e ElemMatch) String() string {
549+
s := make([]string, 0, len(e.Exps))
550+
for _, v := range e.Exps {
551+
s = append(s, v.String())
552+
}
553+
return quoteField(e.Field) + ": {" + opElemMatch + ": {" + strings.Join(s, ", ") + "}}"
554+
}

0 commit comments

Comments
 (0)