diff --git a/ast/builtins.go b/ast/builtins.go index 83fa6066e8..86847ec73a 100644 --- a/ast/builtins.go +++ b/ast/builtins.go @@ -157,6 +157,7 @@ var DefaultBuiltins = [...]*Builtin{ // Object Manipulation ObjectUnion, + ObjectUnionN, ObjectRemove, ObjectFilter, ObjectGet, @@ -1330,6 +1331,20 @@ var ObjectUnion = &Builtin{ ), } +// ObjectUnionN creates a new object that is the asymmetric union of all objects merged from left to right +var ObjectUnionN = &Builtin{ + Name: "object.union_n", + Decl: types.NewFunction( + types.Args( + types.NewArray( + nil, + types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)), + ), + ), + types.A, + ), +} + // ObjectRemove Removes specified keys from an object var ObjectRemove = &Builtin{ Name: "object.remove", diff --git a/capabilities.json b/capabilities.json index 03339eb5e8..e8736d8d0f 100644 --- a/capabilities.json +++ b/capabilities.json @@ -2558,6 +2558,31 @@ "type": "function" } }, + { + "name": "object.union_n", + "decl": { + "args": [ + { + "dynamic": { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + }, + "type": "array" + } + ], + "result": { + "type": "any" + }, + "type": "function" + } + }, { "name": "opa.runtime", "decl": { diff --git a/docs/content/policy-reference.md b/docs/content/policy-reference.md index 2e603ec34f..853e47ee2d 100644 --- a/docs/content/policy-reference.md +++ b/docs/content/policy-reference.md @@ -343,7 +343,8 @@ complex types. | ------- |-------------|---------------| | `value := object.get(object, key, default)` | `value` is the value stored by the `object` at `key`. If no value is found, `default` is returned. | ✅ | | `output := object.remove(object, keys)` | `output` is a new object which is the result of removing the specified `keys` from `object`. `keys` must be either an array, object, or set of keys. | ✅ | -| `output := object.union(objectA, objectB)` | `output` is a new object which is the result of an asymmetric recursive union of two objects where conflicts are resolved by choosing the key from the right-hand object (`objectB`). For example: `object.union({"a": 1, "b": 2, "c": {"d": 3}}, {"a": 7, "c": {"d": 4, "e": 5}})` will result in `{"a": 7, "b": 2, "c": {"d": 4, "e": 5}}` | ✅ | +| `output := object.union(objectA, objectB)` | `output` is a new object which is the result of an asymmetric recursive union of two objects where conflicts are resolved by choosing the key from the right-hand object (`objectB`). For example: `object.union({"a": 1, "b": 2, "c": {"d": 3}}, {"a": 7, "c": {"d": 4, "e": 5}})` will result in `{"a": 7, "b": 2, "c": {"d": 4, "e": 5}}` | ✅ | +| `output := object.union_n(array)` | `output` is a new object which is the result of an asymmetric recursive union of all objects in `array`, merged from left to right, where conflicts are resolved by choosing the key from the right-hand object. For example: `object.union_n([{"a": 1}, {"b": 2}, {"a": 3}])` will result in `{"b": 2, "a": 3}` | ``SDK-dependent`` | | `filtered := object.filter(object, keys)` | `filtered` is a new object with the remaining data from `object` with only keys specified in `keys` which is an array, object, or set of keys. For example: `object.filter({"a": {"b": "x", "c": "y"}, "d": "z"}, ["a"])` will result in `{"a": {"b": "x", "c": "y"}}`). | ✅ | | `filtered := json.filter(object, paths)` | `filtered` is the remaining data from `object` with only keys specified in `paths` which is an array or set of JSON string paths. For example: `json.filter({"a": {"b": "x", "c": "y"}}, ["a/b"])` will result in `{"a": {"b": "x"}}`). Paths are not filtered in-order and are deduplicated before being evaluated. | ✅ | | `output := json.remove(object, paths)` | `output` is a new object which is the result of removing all keys specified in `paths` which is an array or set of JSON string paths. For example: `json.remove({"a": {"b": "x", "c": "y"}}, ["a/b"])` will result in `{"a": {"c": "y"}}`. Paths are not removed in-order and are deduplicated before being evaluated. | ✅ | diff --git a/test/cases/testdata/objectunionn/test-objectunionn-0001.yaml b/test/cases/testdata/objectunionn/test-objectunionn-0001.yaml new file mode 100644 index 0000000000..50ade37d89 --- /dev/null +++ b/test/cases/testdata/objectunionn/test-objectunionn-0001.yaml @@ -0,0 +1,67 @@ +cases: +- note: objectunionn/empty array + modules: + - | + package test + + p := object.union_n([{}]) + + query: data.test.p = x + want_result: + - x: {} + +- note: objectunionn/single item array + modules: + - | + package test + + p := object.union_n([{"foo": "bar"}]) + + query: data.test.p = x + want_result: + - x: {"foo": "bar"} + +- note: objectunionn/merge objects + modules: + - | + package test + + x := object.union_n([{"foo": "bar"}, {"x": "y"}]) + + query: data.test.x = x + want_result: + - x: {"foo": "bar", "x": "y"} + +- note: objectunionn/merge objects conflict + modules: + - | + package test + + x := object.union_n([{"foo": "bar"}, {"foo": "baz"}]) + + query: data.test.x = x + want_result: + - x: {"foo": "baz"} + +- note: objectunionn/merge objects extended + modules: + - | + package test + + x := object.union_n([{ + "a": 1, + "b": 2, + "c": 3, + }, { + "foo": "baz", + "a": "a", + "b": 2, + "d": 4, + }, { + "a": "final A!", + "e": 5.0, + }]) + + query: data.test.x = x + want_result: + - x: {"foo": "baz", "a": "final A!", "b": 2, "c": 3, "d": 4, "e": 5.0} diff --git a/topdown/object.go b/topdown/object.go index ff01b4f96b..4dfd9d6ae4 100644 --- a/topdown/object.go +++ b/topdown/object.go @@ -26,6 +26,25 @@ func builtinObjectUnion(_ BuiltinContext, operands []*ast.Term, iter func(*ast.T return iter(ast.NewTerm(r)) } +func builtinObjectUnionN(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + arr, err := builtins.ArrayOperand(operands[0].Value, 1) + if err != nil { + return err + } + + r := ast.NewObject() + arr.Foreach(func(t *ast.Term) { + var o ast.Object + o, err = builtins.ObjectOperand(t.Value, 1) + r = mergeWithOverwrite(r, o) + }) + if err != nil { + return err + } + + return iter(ast.NewTerm(r)) +} + func builtinObjectRemove(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { // Expect an object and an array/set/object of keys obj, err := builtins.ObjectOperand(operands[0].Value, 1) @@ -134,6 +153,7 @@ func mergeWithOverwrite(objA, objB ast.Object) ast.Object { func init() { RegisterBuiltinFunc(ast.ObjectUnion.Name, builtinObjectUnion) + RegisterBuiltinFunc(ast.ObjectUnionN.Name, builtinObjectUnionN) RegisterBuiltinFunc(ast.ObjectRemove.Name, builtinObjectRemove) RegisterBuiltinFunc(ast.ObjectFilter.Name, builtinObjectFilter) RegisterBuiltinFunc(ast.ObjectGet.Name, builtinObjectGet)