Skip to content

Commit

Permalink
Merge pull request #3 from zelloptt/patch-support-full-attribute-path
Browse files Browse the repository at this point in the history
Patch support full attribute path
  • Loading branch information
ihorserba authored Apr 4, 2024
2 parents 9ad3a35 + 23ccdf0 commit 6ecc58e
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 17 deletions.
16 changes: 15 additions & 1 deletion pkg/v2/crud/crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,23 @@ func Add(resource *prop.Resource, path string, value interface{}) error {
return err
}

return defaultTraverse(resource.RootProperty(), skipMainSchemaNamespace(resource, head), func(nav prop.Navigator) error {
isFound := false
err = defaultTraverse(resource.RootProperty(), skipMainSchemaNamespace(resource, head), func(nav prop.Navigator) error {
isFound = true
return nav.Add(value).Error()
})
if err != nil || isFound {
return err
}
// If not found - add a new property using the values from eq filter operator
cb := func(nav prop.Navigator, value interface{}) error {
if err := nav.Error(); err != nil {
return err
}
nav.Add(value)
return nil
}
return eqFilterTraverse(value, resource.RootProperty(), skipMainSchemaNamespace(resource, head), cb)
}

// Replace value in SCIM resource at the given SCIM path. If SCIM path is empty, the root of the resource
Expand Down
17 changes: 17 additions & 0 deletions pkg/v2/crud/crud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,23 @@ func (s *CrudTestSuite) TestAdd() {
assert.Equal(t, "6546579", r.Navigator().Dot("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User").Dot("employeeNumber").Current().Raw())
},
},
{
name: "add a non-existent property using eq filter path into an empty complex multiValued property",
getResource: func(t *testing.T) *prop.Resource {
return prop.NewResource(s.resourceType)
},
path: `emails[primary eq true].value`,
value: "bar",
expect: func(t *testing.T, r *prop.Resource, err error) {
assert.Nil(t, err)
assert.Equal(t, []interface{}{
map[string]interface{}{
"value": "bar",
"primary": true,
},
}, r.Navigator().Dot("emails").Current().Raw())
},
},
}

for _, test := range tests {
Expand Down
167 changes: 154 additions & 13 deletions pkg/v2/crud/traverse.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,79 @@ import (
"github.com/imulab/go-scim/pkg/v2/spec"
)

func defaultTraverse(property prop.Property, query *expr.Expression, callback func(nav prop.Navigator) error) error {
type traverseCb func(nav prop.Navigator) error
type traverseValueModifiedCb func(nav prop.Navigator, value interface{}) error

func defaultTraverse(property prop.Property, query *expr.Expression, callback traverseCb) error {
cb := func(nav prop.Navigator, query *expr.Expression) error {
return callback(nav)
}
tr := traverser{
nav: prop.Navigate(property),
callback: cb,
elementStrategy: selectAllStrategy,
traverseStrategy: traverseAll,
}
return tr.traverse(query)
}

// A single 'Eq' filter can be used to add a new attribute.
// This traverse calls the callback with the modified value using such filter.
// The operation like:
//
// {
// "op": "add",
// "path": "emails[type eq \"work\"].value",
// "value": "[email protected]"
// }
//
// Adds a new property:
//
// "emails": [
// {
// "type": "work",
// "value": "[email protected]"
// }
// ]
func eqFilterTraverse(value interface{}, property prop.Property, query *expr.Expression, callback traverseValueModifiedCb) error {
cb := func(nav prop.Navigator, query *expr.Expression) error {
v, err := composeValueByEqFilter(value, query, nav)
if err != nil {
return err
}
return callback(nav, v)
}
return traverser{
nav: prop.Navigate(property),
callback: callback,
elementStrategy: selectAllStrategy,
nav: prop.Navigate(property),
callback: cb,
elementStrategy: selectAllStrategy,
traverseStrategy: traverseToSingleEqFilter,
}.traverse(query)
}

func primaryOrFirstTraverse(property prop.Property, query *expr.Expression, callback func(nav prop.Navigator) error) error {
func primaryOrFirstTraverse(property prop.Property, query *expr.Expression, callback traverseCb) error {
cb := func(nav prop.Navigator, query *expr.Expression) error {
return callback(nav)
}
return traverser{
nav: prop.Navigate(property),
callback: callback,
elementStrategy: primaryOrFirstStrategy,
nav: prop.Navigate(property),
callback: cb,
elementStrategy: primaryOrFirstStrategy,
traverseStrategy: traverseAll,
}.traverse(query)
}

type traverser struct {
nav prop.Navigator // stateful navigator for the resource being traversed
callback func(nav prop.Navigator) error // callback function to be invoked when target is reached
elementStrategy elementStrategy // strategy to select element properties to traverse for multiValued properties
nav prop.Navigator // stateful navigator for the resource being traversed
elementStrategy elementStrategy // strategy to select element properties to traverse for multiValued properties
traverseStrategy traverseStrategy // strategy to stop traversing the query
callback func(nav prop.Navigator, query *expr.Expression) error // callback to be invoked when target is reached
}

func (t traverser) traverse(query *expr.Expression) error {
if query == nil {
return t.callback(t.nav)
traverseDone := t.traverseStrategy()
if traverseDone(t.nav, query) {
return t.callback(t.nav, query)
}

if query.IsRootOfFilter() {
Expand All @@ -49,6 +97,53 @@ func (t traverser) traverse(query *expr.Expression) error {
return t.traverseNext(query)
}

func composeValueByEqFilter(value interface{}, query *expr.Expression, nav prop.Navigator) (interface{}, error) {
var err error
var filterValue interface{}
keyValue := ""
filterKey := ""

if query == nil {
return nil, fmt.Errorf("%w: no filter found", spec.ErrInvalidFilter)
}

if query.Left() != nil && query.Left().IsPath() {
filterKey = query.Left().Token()
}
if query.Next() != nil && query.Next().IsPath() {
if query.Next().Next() != nil {
return nil, fmt.Errorf("%w: only a single Eq filter is applicable", spec.ErrInvalidFilter)
}
keyValue = query.Next().Token()
}
if filterKey == "" || keyValue == "" {
return nil, fmt.Errorf("%w: filter is not supported", spec.ErrInvalidFilter)
}
if query.Right() != nil && query.Right().IsLiteral() {
// add a child to the copy of the target property to parse allowed type of filterValue
propCopy := nav.Current().Clone()
navCopy := prop.Navigate(propCopy)
navCopy.Add(map[string]interface{}{})
navCopy.At(0).Dot(filterKey)
if navCopy.HasError() {
// the child does not have a sub property by filterKey
return nil, fmt.Errorf("%w: invalid filter: %w", spec.ErrInvalidFilter, navCopy.Error())
}
filterValue, err = evaluator{}.normalize(
navCopy.Current().Attribute(),
query.Right().Token(),
)
if err != nil {
return nil, fmt.Errorf("%w: invalid filter value: %w", spec.ErrInvalidFilter, err)
}
}
return []interface{}{
map[string]interface{}{
keyValue: value,
filterKey: filterValue,
}}, nil
}

func (t traverser) traverseNext(query *expr.Expression) error {
t.nav.Dot(query.Token())
if err := t.nav.Error(); err != nil {
Expand Down Expand Up @@ -96,6 +191,52 @@ func (t traverser) traverseQualifiedElements(filter *expr.Expression) error {
})
}

type traverseStrategy func() func(nav prop.Navigator, query *expr.Expression) bool

var (
// strategy to traverse all query
traverseAll traverseStrategy = func() func(nav prop.Navigator, query *expr.Expression) bool {
return func(nav prop.Navigator, query *expr.Expression) bool {
return query == nil
}
}

// strategy to get the root of the only Eq filter
traverseToSingleEqFilter traverseStrategy = func() func(nav prop.Navigator, query *expr.Expression) bool {
return func(nav prop.Navigator, query *expr.Expression) bool {
if query == nil {
// If query has been traversed and there is no Eq filter - finish the traverse
return true
}
if !query.IsRootOfFilter() {
// Looking for the root of an Eq filter
return false
}
if !nav.Current().Attribute().MultiValued() {
// Filter is not applicable to a singular attribute
return false
}
if query.Token() != expr.Eq {
// Only an Eq filter is supported
return false
}
if query.Left() == nil || !query.Left().IsPath() {
// The left expression should reflect an attribute path
return false
}
if query.Next() == nil || !query.Next().IsPath() || query.Next().Next() != nil {
// Only a single non-complex filter is supported
return false
}
if query.Right() == nil || !query.Right().IsLiteral() {
// The right expression should be a value assignable to an attribute
return false
}
return true
}
}
)

type elementStrategy func(multiValuedComplex prop.Property) func(index int, child prop.Property) bool

var (
Expand Down
55 changes: 52 additions & 3 deletions pkg/v2/json/deserialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,31 @@ Skip:
return string(d.data[start+1 : end-1]), nil
}

// For the given dot-separated path - return an array of simple names in stack order.
// These names could be consumed by the prop.Navigator.Dot() method.
// For example for the path = "name.givenName"
// return ["givenName", "name"]
func (d *deserializeState) getPropertyDotNamesByPath(path string) []string {
var names []string
var findComplexPath func(child prop.Property) bool

findComplexPath = func(child prop.Property) bool {
if child.Attribute().Path() == path {
// Put the leaf value in the first element of the array
names = append(names, child.Attribute().Name())
return true
}
if child.CountChildren() > 0 && child.FindChild(findComplexPath) != nil {
// Put the rest of the names in a stack order
names = append(names, child.Attribute().Name())
return true
}
return false
}
d.navigator.Current().FindChild(findComplexPath)
return names
}

// Parses a top level or embedded JSON object. When parsing a top level object, allowNull shall be false as top level
// object does not correspond to any field name and hence cannot be null; when parsing an embedded object, allowNull may
// be true. This method expects '{' (appears as scanBeginObject) to be the current byte
Expand All @@ -151,6 +176,7 @@ func (d *deserializeState) parseComplexProperty(allowNull bool) error {
kvs:
for d.opCode != scanEndObject {
// Focus on the property that corresponds to the field name
propertyDepth := 0
var (
p prop.Property
err error
Expand All @@ -161,8 +187,29 @@ kvs:
return err
}
p = d.navigator.Dot(attrName).Current()
if d.navigator.Error() != nil {
return d.navigator.Error()
if err := d.navigator.Error(); err == nil {
// Navigator changes focus only if there was no error
propertyDepth++
} else {
// parseFieldName() may return a complex dot-separated path that navigator.Dot() fails to accept
// parse such path and save the simple attribute names in stack order
d.navigator.ClearError()
names := d.getPropertyDotNamesByPath(attrName)
if len(names) == 0 {
return err
}
for i := range names {
p = d.navigator.Dot(names[len(names)-i-1]).Current()
if d.navigator.Error() == nil {
propertyDepth++
continue
}
// in case of Navigator error - restore the focus and return the initial error
for i := 0; i < propertyDepth; i++ {
d.navigator.Retract()
}
return err
}
}
}

Expand All @@ -177,7 +224,9 @@ kvs:
}

// Exit focus on the field value property
d.navigator.Retract()
for i := 0; i < propertyDepth; i++ {
d.navigator.Retract()
}

// Fast forward to the next field name/value pair, or exit the loop.
fastForward:
Expand Down
15 changes: 15 additions & 0 deletions pkg/v2/json/deserialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ func (s *JsonDeserializeTestSuite) TestDeserializeResource() {
assert.Equal(t, true, resource.Navigator().Dot("active").Current().Raw())
},
},
{
name: "Dot-separated path",
json: `
{
"schemas":[
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"id":"a93d0d53-e9d5-4706-854a-065fae8628bd",
"name.givenName":"Ihor"
}
`,
expect: func(t *testing.T, resource *prop.Resource, err error) {
assert.Equal(t, "Ihor", resource.Navigator().Dot("name").Dot("givenName").Current().Raw())
},
},
}

for _, test := range tests {
Expand Down

0 comments on commit 6ecc58e

Please sign in to comment.