Skip to content

Commit

Permalink
Merge pull request #767 from owncloud/ocis-697
Browse files Browse the repository at this point in the history
Support OData for search queries on the Indexer
  • Loading branch information
butonic authored Nov 2, 2020
2 parents e13b2ef + d378f07 commit 3cab05a
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 101 deletions.
1 change: 1 addition & 0 deletions accounts/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/owncloud/ocis/accounts
go 1.13

require (
github.com/CiscoM31/godata v0.0.0-20201003040028-eadcd34e7f06
github.com/cs3org/go-cs3apis v0.0.0-20200810113633-b00aca449666
github.com/cs3org/reva v1.2.2-0.20200924071957-e6676516e61e
github.com/go-chi/chi v4.1.2+incompatible
Expand Down
2 changes: 2 additions & 0 deletions accounts/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzS
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/CiscoM31/godata v0.0.0-20201003040028-eadcd34e7f06 h1:FKxVU/j9Dd8Je0YkVkm8Fxpz9zIeN21SEkcbzA6NWgY=
github.com/CiscoM31/godata v0.0.0-20201003040028-eadcd34e7f06/go.mod h1:tjaihnMBH6p5DVnGBksDQQHpErbrLvb9ek6cEWuyc7E=
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg=
github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
Expand Down
15 changes: 15 additions & 0 deletions accounts/pkg/indexer/helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package indexer

// dedup removes duplicate values in given slice
func dedup(s []string) []string {
for i := 0; i < len(s); i++ {
for i2 := i + 1; i2 < len(s); i2++ {
if s[i] == s[i2] {
// delete
s = append(s[:i2], s[i2+1:]...)
i2--
}
}
}
return s
}
150 changes: 150 additions & 0 deletions accounts/pkg/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package indexer
import (
"fmt"
"path"
"strings"

"github.com/CiscoM31/godata"
"github.com/iancoleman/strcase"
"github.com/owncloud/ocis/accounts/pkg/config"
"github.com/owncloud/ocis/accounts/pkg/indexer/errors"
Expand Down Expand Up @@ -236,3 +238,151 @@ func (i Indexer) Update(from, to interface{}) error {

return nil
}

// Query parses an OData query into something our indexer.Index understands and resolves it.
func (i Indexer) Query(t interface{}, q string) ([]string, error) {
query, err := godata.ParseFilterString(q)
if err != nil {
return nil, err
}

tree := newQueryTree()
if err := buildTreeFromOdataQuery(query.Tree, &tree); err != nil {
return nil, err
}

results := make([]string, 0)
if err := i.resolveTree(t, &tree, &results); err != nil {
return nil, err
}

return results, nil
}

// t is used to infer the indexed field names. When building an index search query, field names have to respect Golang
// conventions and be in PascalCase. For a better overview on this contemplate reading the reflection package under the
// indexer directory. Traversal of the tree happens in a pre-order fashion.
// TODO implement logic for `and` operators.
func (i Indexer) resolveTree(t interface{}, tree *queryTree, partials *[]string) error {
if partials == nil {
return fmt.Errorf("return value cannot be nil: partials")
}

if tree.left != nil {
_ = i.resolveTree(t, tree.left, partials)
}

if tree.right != nil {
_ = i.resolveTree(t, tree.right, partials)
}

// by the time we're here we reached a leaf node.
if tree.token != nil {
switch tree.token.filterType {
case "FindBy":
operand, err := sanitizeInput(tree.token.operands)
if err != nil {
return err
}

r, err := i.FindBy(t, operand.field, operand.value)
if err != nil {
return err
}

*partials = append(*partials, r...)
case "FindByPartial":
operand, err := sanitizeInput(tree.token.operands)
if err != nil {
return err
}

r, err := i.FindByPartial(t, operand.field, fmt.Sprintf("%v*", operand.value))
if err != nil {
return err
}

*partials = append(*partials, r...)
default:
return fmt.Errorf("unsupported filter: %v", tree.token.filterType)
}
}

*partials = dedup(*partials)
return nil
}

type indexerTuple struct {
field, value string
}

// sanitizeInput returns a tuple of fieldName + value to be applied on indexer.Index filters.
func sanitizeInput(operands []string) (*indexerTuple, error) {
if len(operands) != 2 {
return nil, fmt.Errorf("invalid number of operands for filter function: got %v expected 2", len(operands))
}

// field names are Go public types and by design they are in PascalCase, therefore we need to adhere to this rules.
// for further information on this have a look at the reflection package.
f := strcase.ToCamel(operands[0])

// remove single quotes from value.
v := strings.ReplaceAll(operands[1], "'", "")
return &indexerTuple{
field: f,
value: v,
}, nil
}

// buildTreeFromOdataQuery builds an indexer.queryTree out of a GOData ParseNode. The purpose of this intermediate tree
// is to transform godata operators and functions into supported operations on our index. At the time of this writing
// we only support `FindBy` and `FindByPartial` queries as these are the only implemented filters on indexer.Index(es).
func buildTreeFromOdataQuery(root *godata.ParseNode, tree *queryTree) error {
if root.Token.Type == godata.FilterTokenFunc { // i.e "startswith", "contains"
switch root.Token.Value {
case "startswith":
token := token{
operator: root.Token.Value,
filterType: "FindByPartial",
// TODO sanitize the number of operands it the expected one.
operands: []string{
root.Children[0].Token.Value, // field name, i.e: Name
root.Children[1].Token.Value, // field value, i.e: Jac
},
}

tree.insert(&token)
default:
return fmt.Errorf("operation not supported")
}
}

if root.Token.Type == godata.FilterTokenLogical {
switch root.Token.Value {
case "or":
tree.insert(&token{operator: root.Token.Value})
for _, child := range root.Children {
if err := buildTreeFromOdataQuery(child, tree.left); err != nil {
return err
}
}
case "eq":
tree.insert(&token{
operator: root.Token.Value,
filterType: "FindBy",
operands: []string{
root.Children[0].Token.Value,
root.Children[1].Token.Value,
},
})
for _, child := range root.Children {
if err := buildTreeFromOdataQuery(child, tree.left); err != nil {
return err
}
}
default:
return fmt.Errorf("operator not supported")
}
}
return nil
}
57 changes: 45 additions & 12 deletions accounts/pkg/indexer/indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,51 @@ func TestIndexer_Disk_UpdateWithNonUniqueIndex(t *testing.T) {
_ = os.RemoveAll(dataDir)
}

//func createCs3Indexer() *Indexer {
// return CreateIndexer(&config.Config{
// Repo: config.Repo{
// CS3: config.CS3{
// ProviderAddr: "0.0.0.0:9215",
// DataURL: "http://localhost:9216",
// DataPrefix: "data",
// JWTSecret: "Pive-Fumkiu4",
// },
// },
// })
//}
func TestQueryDiskImpl(t *testing.T) {
dataDir, err := WriteIndexTestData(Data, "ID", "")
assert.NoError(t, err)
indexer := createDiskIndexer(dataDir)

err = indexer.AddIndex(&Account{}, "OnPremisesSamAccountName", "ID", "accounts", "non_unique", nil, false)
assert.NoError(t, err)

err = indexer.AddIndex(&Account{}, "Mail", "ID", "accounts", "non_unique", nil, false)
assert.NoError(t, err)

err = indexer.AddIndex(&Account{}, "ID", "ID", "accounts", "non_unique", nil, false)
assert.NoError(t, err)

acc := Account{
ID: "ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2",
Mail: "spooky@skeletons.org",
OnPremisesSamAccountName: "MrDootDoot",
}

_, err = indexer.Add(acc)
assert.NoError(t, err)

r, err := indexer.Query(&Account{}, "on_premises_sam_account_name eq 'MrDootDoot'") // this query will match both pets.
assert.NoError(t, err)
assert.Equal(t, []string{"ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2"}, r)

r, err = indexer.Query(&Account{}, "mail eq 'spooky@skeletons.org'") // this query will match both pets.
assert.NoError(t, err)
assert.Equal(t, []string{"ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2"}, r)

r, err = indexer.Query(&Account{}, "on_premises_sam_account_name eq 'MrDootDoot' or mail eq 'spooky@skeletons.org'") // this query will match both pets.
assert.NoError(t, err)
assert.Equal(t, []string{"ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2"}, r)

r, err = indexer.Query(&Account{}, "startswith(on_premises_sam_account_name,'MrDoo')") // this query will match both pets.
assert.NoError(t, err)
assert.Equal(t, []string{"ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2"}, r)

r, err = indexer.Query(&Account{}, "id eq 'ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2' or on_premises_sam_account_name eq 'MrDootDoot'") // this query will match both pets.
assert.NoError(t, err)
assert.Equal(t, []string{"ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2"}, r)

_ = os.RemoveAll(dataDir)
}

func createDiskIndexer(dataDir string) *Indexer {
return CreateIndexer(&config.Config{
Expand Down
40 changes: 40 additions & 0 deletions accounts/pkg/indexer/query_tree.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package indexer

type queryTree struct {
token *token
root bool
left *queryTree
right *queryTree
}

// token to be resolved by the index
type token struct {
operator string // original OData operator. i.e: 'startswith', `or`, `and`.
filterType string // equivalent operator from OData -> indexer i.e FindByPartial or FindBy.
operands []string
}

// newQueryTree constructs a new tree with a root node.
func newQueryTree() queryTree {
return queryTree{
root: true,
}
}

// insert populates first the LHS of the tree first, if this is not possible it fills the RHS.
func (t *queryTree) insert(tkn *token) {
if t != nil && t.root {
t.left = &queryTree{token: tkn}
return
}

if t.left == nil {
t.left = &queryTree{token: tkn}
return
}

if t.left != nil && t.right == nil {
t.right = &queryTree{token: tkn}
return
}
}
10 changes: 10 additions & 0 deletions accounts/pkg/indexer/test/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ type Pet struct {
UID int
}

// Account mocks an ocis account.
type Account struct {
ID string
OnPremisesSamAccountName string
Mail string
}

// Data mock data.
var Data = map[string][]interface{}{
"users": {
Expand All @@ -33,6 +40,9 @@ var Data = map[string][]interface{}{
Pet{ID: "goefe-789", Kind: "Hog", Color: "Green", Name: "Dicky"},
Pet{ID: "xadaf-189", Kind: "Hog", Color: "Green", Name: "Ricky"},
},
"accounts": {
Account{ID: "ba5b6e54-e29d-4b2b-8cc4-0a0b958140d2", Mail: "spooky@skeletons.org", OnPremisesSamAccountName: "MrDootDoot"},
},
}

// WriteIndexTestData writes mock data to disk.
Expand Down
Loading

0 comments on commit 3cab05a

Please sign in to comment.