From 8b6920df705a8acd4c07f79e690d74486655d28b Mon Sep 17 00:00:00 2001 From: Paulin Todev Date: Fri, 4 Oct 2024 10:57:50 +0100 Subject: [PATCH 1/4] Prototype map.inner_join function --- docs/sources/reference/stdlib/map.md | 35 ++++++ syntax/internal/stdlib/stdlib.go | 181 +++++++++++++++++++++++++++ syntax/internal/value/value.go | 18 ++- syntax/internal/value/value_test.go | 17 +++ syntax/vm/vm_stdlib_test.go | 22 ++++ 5 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 docs/sources/reference/stdlib/map.md diff --git a/docs/sources/reference/stdlib/map.md b/docs/sources/reference/stdlib/map.md new file mode 100644 index 0000000000..0cb8726b8e --- /dev/null +++ b/docs/sources/reference/stdlib/map.md @@ -0,0 +1,35 @@ +--- +canonical: https://grafana.com/docs/alloy/latest/reference/stdlib/map/ +description: Learn about map functions +aliases: + - ./concat/ # /docs/alloy/latest/reference/stdlib/concat/ +menuTitle: map +title: map +--- + +# map + +The `map` namespace contains functions related to objects. + +## map.inner_join + +The `map.inner_join` function allows you to join two arrays containing maps if certain keys have matching values in both maps. +It takes several inputs, in this order: + +1. An array of maps (objects) which will be joined. The keys of the map are strings. + Its value could have any Alloy type such as a string, integer, map, or a capsule. +2. An array of maps (objects) which will be joined. The keys of the map are strings. + Its value could have any Alloy type such as a string, integer, map, or a capsule. +3. An array containing strings. The strings are the keys whose value has to match for maps to be joined. + If the set of keys don't identify a map uniquely, the resulting output may contain more maps than the total sum of maps from both input arrays. +4. (optional; default: `"update"`) A merge strategy. It describes which value will be used if there is Can be set to either of: + * `update`: If there is already a key with the same name in the first map, it will be updated with the value from the second map. + * `none`: If both maps have different values for the same key, no such key will be present in the output map. +5. (optional; default: `[]`) An array containing strings. Only keys listed in the array will be present in the output map. +6. (optional; default: `[]`) An array containing strings. Keyls listed in the array will not be present in the output map. + +### Examples + +```alloy +map.inner_join(prometheus.exporter.unix.default.targets, [{"instance"="1.1.1.1", "testLabelKey" = "testLabelVal", "BadKey" = "BadVal"}], ["instance"], "update", [], ["BadKey"]) +``` \ No newline at end of file diff --git a/syntax/internal/stdlib/stdlib.go b/syntax/internal/stdlib/stdlib.go index 995e538d4b..e7e9bd7767 100644 --- a/syntax/internal/stdlib/stdlib.go +++ b/syntax/internal/stdlib/stdlib.go @@ -52,6 +52,7 @@ var Identifiers = map[string]interface{}{ "sys": sys, "convert": convert, "array": array, + "map": mapFuncs, "encoding": encoding, "string": str, "file": file, @@ -89,6 +90,10 @@ var array = map[string]interface{}{ "concat": concat, } +var mapFuncs = map[string]interface{}{ + "inner_join": innerJoin, +} + var convert = map[string]interface{}{ "nonsensitive": nonSensitive, } @@ -146,6 +151,182 @@ var concat = value.RawFunction(func(funcValue value.Value, args ...value.Value) return value.Array(raw...), nil }) +func shouldJoinOld(left map[string]value.Value, right map[string]value.Value, conditions []string) bool { + for _, c := range conditions { + if !left[c].Equal(right[c]) { + return false + } + } + return true +} + +func concatMapsOld(left map[string]value.Value, right map[string]value.Value) map[string]value.Value { + res := make(map[string]value.Value) + for k, v := range left { + res[k] = v + } + for k, v := range right { + res[k] = v + } + return res +} + +// We can assume that conditions is an value.TypeArray, +// because it is checked in the innerJoin function. +func shouldJoin(left, right value.Value, conditions value.Value) bool { + for i := 0; i < conditions.Len(); i++ { + c := conditions.Index(i) + // if c.Type() != value.TypeString { + // //TODO: Throw error + // panic("") + // } + condition := c.Text() + + // if left.Type() != value.TypeArray || right.Type() != value.TypeArray { + // //TODO: Throw error + // panic("") + // } + + leftVal, ok := left.Key(condition) + if !ok { + //TODO: Throw error + panic("") + } + + rightVal, ok := right.Key(condition) + if !ok { + //TODO: Throw error + panic("") + } + + if !leftVal.Equal(rightVal) { + return false + } + } + return true +} + +// TODO: Make strategy an enum +func concatMaps(left, right value.Value, strategy string, allowlist, denylist map[string]struct{}) value.Value { + res := make(map[string]value.Value) + + for _, key := range left.Keys() { + + // Insert the value into the result map + val, ok := left.Key(key) + if !ok { + //TODO: Throw error + panic("") + } + res[key] = val + } + + for _, key := range right.Keys() { + if len(denylist) > 0 { + // Make sure the key is not in the denylist + _, found := denylist[key] + if found { + continue + } + } + + if len(allowlist) > 0 { + // Make sure we only insert keys listed in the allowlist + _, found := allowlist[key] + if !found { + continue + } + } + + val, ok := right.Key(key) + if !ok { + //TODO: Throw error + panic("") + } + res[key] = val + } + + return value.Object(res) +} + +// TODO: What if conditions are empty? +// Inputs: +// 1. []map[string]string +// 2. []map[string]string +// 3. []string +// 4. string: update strategy +// 5. []string: key allowlist +// 6. []string: key denylist +var innerJoin = value.RawFunction(func(funcValue value.Value, args ...value.Value) (value.Value, error) { + // left := make([]map[string]value.Value, 0) + // right := make([]map[string]value.Value, 0) + // conditions := make([]string, 0) + + //TODO: Should the last 3 params be optional? + if len(args) < 3 { + return value.Value{}, fmt.Errorf("inner_join: expected at lest 3 arguments, got %d", len(args)) + } + + for i := range []int{0, 1} { + if args[i].Type() != value.TypeArray { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[i], + Index: i, + Inner: value.TypeError{ + Value: args[i], + Expected: value.TypeArray, + }, + } + } + for j := 0; j < args[i].Len(); j++ { + if args[i].Index(j).Type() != value.TypeObject { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[i].Index(j), + Index: j, + Inner: value.TypeError{ + Value: args[i].Index(j), + Expected: value.TypeObject, + }, + } + } + } + } + + //TODO: Preallocate memory? + res := make([]value.Value, 0, 0) + + conditions := args[2] + + var strategy string + if len(args) >= 4 { + strategy = args[3].Text() + } + + allowlist := make(map[string]struct{}, args[4].Len()) + for i := 0; i < args[4].Len(); i++ { + allowlist[args[4].Index(i).Text()] = struct{}{} + } + + denylist := make(map[string]struct{}, args[5].Len()) + for i := 0; i < args[5].Len(); i++ { + denylist[args[5].Index(i).Text()] = struct{}{} + } + + for i := 0; i < args[0].Len(); i++ { + for j := 0; j < args[1].Len(); j++ { + left := args[0].Index(i) + right := args[1].Index(j) + if shouldJoin(left, right, conditions) { + res = append(res, concatMaps(left, right, strategy, allowlist, denylist)) + } + } + } + + return value.Array(res...), nil +}) + func jsonDecode(in string) (interface{}, error) { var res interface{} err := json.Unmarshal([]byte(in), &res) diff --git a/syntax/internal/value/value.go b/syntax/internal/value/value.go index 3c8554b88c..0925012094 100644 --- a/syntax/internal/value/value.go +++ b/syntax/internal/value/value.go @@ -221,9 +221,11 @@ func (v Value) Text() string { } } -// Len returns the length of v. Panics if v is not an array or object. +// Len returns the length of v. Panics if v is not an array, an object, or nil. func (v Value) Len() int { switch v.ty { + case TypeNull: + return 0 case TypeArray: return v.rv.Len() case TypeObject: @@ -396,7 +398,7 @@ func (v Value) Key(key string) (index Value, ok bool) { // // An ArgError will be returned if one of the arguments is invalid. An Error // will be returned if the function call returns an error or if the number of -// arguments doesn't match. +// arguments doesn't match func (v Value) Call(args ...Value) (Value, error) { if v.ty != TypeFunction { panic("syntax/value: Call called on non-function type") @@ -553,3 +555,15 @@ func convertGoNumber(nval Number, target reflect.Type) reflect.Value { panic("unsupported number conversion") } + +func (v Value) Equal(rhs Value) bool { + if v.Type() != rhs.Type() { + return false + } + + if !v.rv.Equal(rhs.rv) { + return false + } + + return true +} diff --git a/syntax/internal/value/value_test.go b/syntax/internal/value/value_test.go index fbebcabdd7..c957be052d 100644 --- a/syntax/internal/value/value_test.go +++ b/syntax/internal/value/value_test.go @@ -128,6 +128,23 @@ func (*pointerMarshaler) MarshalText() ([]byte, error) { } func TestValue_Call(t *testing.T) { + // t.Run("slices with maps", func(t *testing.T) { + // add := func(a, b []map[string]string) []map[string]string { + // res := make([]map[string]string, 0, len(a)+len(b)) + // res = append(res, a...) + // res = append(res, b...) + // return res + // } + // addVal := value.Encode(add) + + // res, err := addVal.Call( + // value.Array(map[string]string{"a": "b"}), + // value.Int(43), + // ) + // require.NoError(t, err) + // require.Equal(t, int64(15+43), res.Int()) + // }) + t.Run("simple", func(t *testing.T) { add := func(a, b int) int { return a + b } addVal := value.Encode(add) diff --git a/syntax/vm/vm_stdlib_test.go b/syntax/vm/vm_stdlib_test.go index e8009ae157..abee4c58e6 100644 --- a/syntax/vm/vm_stdlib_test.go +++ b/syntax/vm/vm_stdlib_test.go @@ -39,6 +39,28 @@ func TestVM_Stdlib(t *testing.T) { {"encoding.from_yaml nil field", "encoding.from_yaml(`foo: null`)", map[string]interface{}{"foo": nil}}, {"encoding.from_yaml nil array element", `encoding.from_yaml("[0, null]")`, []interface{}{0, nil}}, {"encoding.from_base64", `encoding.from_base64("Zm9vYmFyMTIzIT8kKiYoKSctPUB+")`, string(`foobar123!?$*&()'-=@~`)}, + + // Map tests + { + "map.inner_join", + `map.inner_join([{"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}}, + }, + { + "map.inner_join", + `map.inner_join([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}, {"a": "a1", "b": "b1", "c": "c1"}}, + }, + { + "map.inner_join", + `map.inner_join([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}, {"a": "a1", "b": "b1", "c": "c1"}}, + }, + { + "map.inner_join", + `map.inner_join([{"a" = 1, "b" = 2.2}], [{"a" = 1, "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": 2.2, "c": "c1"}}, + }, } for _, tc := range tt { From dc0a3cd49a1bd60e63825c4624605828f7e70a5e Mon Sep 17 00:00:00 2001 From: Paulin Todev Date: Wed, 9 Oct 2024 10:46:57 +0100 Subject: [PATCH 2/4] Rename to "targets.merge", remove unnecessary params --- docs/sources/reference/stdlib/map.md | 35 ----- docs/sources/reference/stdlib/targets.md | 38 ++++++ syntax/internal/stdlib/stdlib.go | 155 ++++++++--------------- syntax/vm/vm_stdlib_test.go | 43 +++++-- 4 files changed, 127 insertions(+), 144 deletions(-) delete mode 100644 docs/sources/reference/stdlib/map.md create mode 100644 docs/sources/reference/stdlib/targets.md diff --git a/docs/sources/reference/stdlib/map.md b/docs/sources/reference/stdlib/map.md deleted file mode 100644 index 0cb8726b8e..0000000000 --- a/docs/sources/reference/stdlib/map.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -canonical: https://grafana.com/docs/alloy/latest/reference/stdlib/map/ -description: Learn about map functions -aliases: - - ./concat/ # /docs/alloy/latest/reference/stdlib/concat/ -menuTitle: map -title: map ---- - -# map - -The `map` namespace contains functions related to objects. - -## map.inner_join - -The `map.inner_join` function allows you to join two arrays containing maps if certain keys have matching values in both maps. -It takes several inputs, in this order: - -1. An array of maps (objects) which will be joined. The keys of the map are strings. - Its value could have any Alloy type such as a string, integer, map, or a capsule. -2. An array of maps (objects) which will be joined. The keys of the map are strings. - Its value could have any Alloy type such as a string, integer, map, or a capsule. -3. An array containing strings. The strings are the keys whose value has to match for maps to be joined. - If the set of keys don't identify a map uniquely, the resulting output may contain more maps than the total sum of maps from both input arrays. -4. (optional; default: `"update"`) A merge strategy. It describes which value will be used if there is Can be set to either of: - * `update`: If there is already a key with the same name in the first map, it will be updated with the value from the second map. - * `none`: If both maps have different values for the same key, no such key will be present in the output map. -5. (optional; default: `[]`) An array containing strings. Only keys listed in the array will be present in the output map. -6. (optional; default: `[]`) An array containing strings. Keyls listed in the array will not be present in the output map. - -### Examples - -```alloy -map.inner_join(prometheus.exporter.unix.default.targets, [{"instance"="1.1.1.1", "testLabelKey" = "testLabelVal", "BadKey" = "BadVal"}], ["instance"], "update", [], ["BadKey"]) -``` \ No newline at end of file diff --git a/docs/sources/reference/stdlib/targets.md b/docs/sources/reference/stdlib/targets.md new file mode 100644 index 0000000000..131030d5d1 --- /dev/null +++ b/docs/sources/reference/stdlib/targets.md @@ -0,0 +1,38 @@ +--- +canonical: https://grafana.com/docs/alloy/latest/reference/stdlib/targets/ +description: Learn about targets functions +menuTitle: targets +title: targets +--- + +# targets + +The `targets` namespace contains functions related to `list(map(string))` arguments. +They are often used by [prometheus][prom-comp] and [discovery][disc-comp] components. +Refer to [Compatible components][] for a full list of components which export and consume targets. + +[prom-comp]: ../components/prometheus/ +[disc-comp]: ../components/discovery/ +[Compatible components]: ../compatibility/#targets + +## targets.merge + +The `targets.inner_join` function allows you to join two arrays containing maps if certain keys have matching values in both maps. +It takes three inputs: + +* The first two inputs are a of type `list(map(string))`. The keys of the map are strings. + The value for each key could have any Alloy type such as a string, integer, map, or a capsule. +* The third input is an array containing strings. The strings are the keys whose value has to match for maps to be joined. + + +If the set of keys don't identify a map uniquely, the resulting output may contain more maps than the total sum of maps from both input arrays. + +### Examples + +```alloy +targets.inner_join(discovery.kubernetes.k8s_pods.targets, prometheus.exporter.postgres, ["instance"]) +``` + +```alloy +targets.inner_join(prometheus.exporter.redis.default.targets, [{"instance"="1.1.1.1", "testLabelKey" = "testLabelVal"}], ["instance"]) +``` \ No newline at end of file diff --git a/syntax/internal/stdlib/stdlib.go b/syntax/internal/stdlib/stdlib.go index e7e9bd7767..0bb23db966 100644 --- a/syntax/internal/stdlib/stdlib.go +++ b/syntax/internal/stdlib/stdlib.go @@ -52,7 +52,7 @@ var Identifiers = map[string]interface{}{ "sys": sys, "convert": convert, "array": array, - "map": mapFuncs, + "targets": targets, "encoding": encoding, "string": str, "file": file, @@ -90,8 +90,8 @@ var array = map[string]interface{}{ "concat": concat, } -var mapFuncs = map[string]interface{}{ - "inner_join": innerJoin, +var targets = map[string]interface{}{ + "merge": targetsMerge, } var convert = map[string]interface{}{ @@ -151,122 +151,63 @@ var concat = value.RawFunction(func(funcValue value.Value, args ...value.Value) return value.Array(raw...), nil }) -func shouldJoinOld(left map[string]value.Value, right map[string]value.Value, conditions []string) bool { - for _, c := range conditions { - if !left[c].Equal(right[c]) { - return false - } - } - return true -} - -func concatMapsOld(left map[string]value.Value, right map[string]value.Value) map[string]value.Value { - res := make(map[string]value.Value) - for k, v := range left { - res[k] = v - } - for k, v := range right { - res[k] = v - } - return res -} - -// We can assume that conditions is an value.TypeArray, -// because it is checked in the innerJoin function. -func shouldJoin(left, right value.Value, conditions value.Value) bool { +// This function assumes that the types of the value.Value objects are correct. +func shouldJoin(left, right value.Value, conditions value.Value) (bool, error) { for i := 0; i < conditions.Len(); i++ { c := conditions.Index(i) - // if c.Type() != value.TypeString { - // //TODO: Throw error - // panic("") - // } condition := c.Text() - // if left.Type() != value.TypeArray || right.Type() != value.TypeArray { - // //TODO: Throw error - // panic("") - // } - leftVal, ok := left.Key(condition) if !ok { - //TODO: Throw error - panic("") + return false, fmt.Errorf("concatMaps: key %s not found in left map", condition) } rightVal, ok := right.Key(condition) if !ok { - //TODO: Throw error - panic("") + return false, fmt.Errorf("concatMaps: key %s not found in right map", condition) } if !leftVal.Equal(rightVal) { - return false + return false, nil } } - return true + return true, nil } -// TODO: Make strategy an enum -func concatMaps(left, right value.Value, strategy string, allowlist, denylist map[string]struct{}) value.Value { +// Merge two maps. +// If a key exists in both maps, the value from the right map will be used. +func concatMaps(left, right value.Value) (value.Value, error) { res := make(map[string]value.Value) for _, key := range left.Keys() { - - // Insert the value into the result map val, ok := left.Key(key) if !ok { - //TODO: Throw error - panic("") + return value.Null, fmt.Errorf("concatMaps: key %s not found in left map", key) } res[key] = val } for _, key := range right.Keys() { - if len(denylist) > 0 { - // Make sure the key is not in the denylist - _, found := denylist[key] - if found { - continue - } - } - - if len(allowlist) > 0 { - // Make sure we only insert keys listed in the allowlist - _, found := allowlist[key] - if !found { - continue - } - } - val, ok := right.Key(key) if !ok { - //TODO: Throw error - panic("") + return value.Null, fmt.Errorf("concatMaps: key %s not found in right map", key) } res[key] = val } - return value.Object(res) + return value.Object(res), nil } -// TODO: What if conditions are empty? // Inputs: -// 1. []map[string]string -// 2. []map[string]string -// 3. []string -// 4. string: update strategy -// 5. []string: key allowlist -// 6. []string: key denylist -var innerJoin = value.RawFunction(func(funcValue value.Value, args ...value.Value) (value.Value, error) { - // left := make([]map[string]value.Value, 0) - // right := make([]map[string]value.Value, 0) - // conditions := make([]string, 0) - - //TODO: Should the last 3 params be optional? - if len(args) < 3 { - return value.Value{}, fmt.Errorf("inner_join: expected at lest 3 arguments, got %d", len(args)) +// args[0]: []map[string]string: lhs array +// args[1]: []map[string]string: rhs array +// args[2]: []string: merge conditions +var targetsMerge = value.RawFunction(func(funcValue value.Value, args ...value.Value) (value.Value, error) { + if len(args) != 3 { + return value.Value{}, fmt.Errorf("inner_join: expected 3 arguments, got %d", len(args)) } + // Validate args[0] and args[1] for i := range []int{0, 1} { if args[i].Type() != value.TypeArray { return value.Null, value.ArgError{ @@ -294,32 +235,48 @@ var innerJoin = value.RawFunction(func(funcValue value.Value, args ...value.Valu } } - //TODO: Preallocate memory? - res := make([]value.Value, 0, 0) - - conditions := args[2] - - var strategy string - if len(args) >= 4 { - strategy = args[3].Text() + // Validate args[2] + if args[2].Type() != value.TypeArray { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[2], + Index: 2, + Inner: value.TypeError{ + Value: args[2], + Expected: value.TypeArray, + }, + } } - - allowlist := make(map[string]struct{}, args[4].Len()) - for i := 0; i < args[4].Len(); i++ { - allowlist[args[4].Index(i).Text()] = struct{}{} + if args[2].Len() == 0 { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[2], + Index: 2, + Inner: fmt.Errorf("inner_join: merge conditions must not be empty"), + } } - denylist := make(map[string]struct{}, args[5].Len()) - for i := 0; i < args[5].Len(); i++ { - denylist[args[5].Index(i).Text()] = struct{}{} - } + // We cannot preallocate the size of the result array, because we don't know + // how well the merge is going to go. If none of the merge conditions are met, + // the result array will be empty. + res := []value.Value{} for i := 0; i < args[0].Len(); i++ { for j := 0; j < args[1].Len(); j++ { left := args[0].Index(i) right := args[1].Index(j) - if shouldJoin(left, right, conditions) { - res = append(res, concatMaps(left, right, strategy, allowlist, denylist)) + + join, err := shouldJoin(left, right, args[2]) + if err != nil { + return value.Null, err + } + + if join { + val, err := concatMaps(left, right) + if err != nil { + return value.Null, err + } + res = append(res, val) } } } diff --git a/syntax/vm/vm_stdlib_test.go b/syntax/vm/vm_stdlib_test.go index abee4c58e6..d1ca8b9c21 100644 --- a/syntax/vm/vm_stdlib_test.go +++ b/syntax/vm/vm_stdlib_test.go @@ -42,24 +42,47 @@ func TestVM_Stdlib(t *testing.T) { // Map tests { - "map.inner_join", - `map.inner_join([{"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + // Basic case. No conflicting key/val pairs. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}}, }, { - "map.inner_join", - `map.inner_join([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + // The first array has 2 maps, each with the same key/val pairs. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}, {"a": "a1", "b": "b1", "c": "c1"}}, }, { - "map.inner_join", - `map.inner_join([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, - []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}, {"a": "a1", "b": "b1", "c": "c1"}}, + // Basic case. Integer and string values. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = 1, "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": 2.2, "c": "c1"}}, }, { - "map.inner_join", - `map.inner_join([{"a" = 1, "b" = 2.2}], [{"a" = 1, "c" = "c1"}], ["a"])`, - []map[string]interface{}{{"a": 1, "b": 2.2, "c": "c1"}}, + // The second map will override a value from the first. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = 1, "b" = "3.3"}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": "3.3"}}, + }, + { + // Not enough matches for a join. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = 2, "b" = "3.3"}], ["a"])`, + []map[string]interface{}{}, + }, + { + // Not enough matches for a join. + // The "a" value has differing types. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = "1", "b" = "3.3"}], ["a"])`, + []map[string]interface{}{}, + }, + { + // Basic case. Some values are arrays and maps. + "targets.merge", + `targets.merge([{"a" = 1, "b" = [1,2,3]}], [{"a" = 1, "c" = {"d" = {"e" = 10}}}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": []interface{}{1, 2, 3}, "c": map[string]interface{}{"d": map[string]interface{}{"e": 10}}}}, }, } From 0ef415c1ecd90d081cabedb7cf5990f893215ef4 Mon Sep 17 00:00:00 2001 From: Paulin Todev Date: Thu, 31 Oct 2024 19:18:38 +0000 Subject: [PATCH 3/4] Add failure tests --- syntax/vm/vm_stdlib_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/syntax/vm/vm_stdlib_test.go b/syntax/vm/vm_stdlib_test.go index d1ca8b9c21..04802fbad2 100644 --- a/syntax/vm/vm_stdlib_test.go +++ b/syntax/vm/vm_stdlib_test.go @@ -100,6 +100,41 @@ func TestVM_Stdlib(t *testing.T) { } } +func TestVM_Stdlib_Errors(t *testing.T) { + tt := []struct { + name string + input string + expectedErr string + }{ + // Map tests + { + // Error: invalid RHS type - string. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}], "a", ["a"])`, + `"a" should be array, got string`, + }, + { + // Error: invalid RHS type - an array with strings. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}], ["a"], ["a"])`, + `"a" should be object, got string`, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + expr, err := parser.ParseExpression(tc.input) + require.NoError(t, err) + + eval := vm.New(expr) + + rv := reflect.New(reflect.TypeOf([]map[string]interface{}{})) + err = eval.Evaluate(nil, rv.Interface()) + require.ErrorContains(t, err, tc.expectedErr) + }) + } +} + func TestStdlibCoalesce(t *testing.T) { t.Setenv("TEST_VAR2", "Hello!") From 955fc04dacd95c23c32f9a38a4622f9fbd717ab8 Mon Sep 17 00:00:00 2001 From: Paulin Todev Date: Thu, 31 Oct 2024 19:29:45 +0000 Subject: [PATCH 4/4] Delete incorrect comment --- docs/sources/reference/stdlib/targets.md | 3 --- syntax/vm/vm_stdlib_test.go | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/sources/reference/stdlib/targets.md b/docs/sources/reference/stdlib/targets.md index 131030d5d1..2f09a891c9 100644 --- a/docs/sources/reference/stdlib/targets.md +++ b/docs/sources/reference/stdlib/targets.md @@ -23,9 +23,6 @@ It takes three inputs: * The first two inputs are a of type `list(map(string))`. The keys of the map are strings. The value for each key could have any Alloy type such as a string, integer, map, or a capsule. * The third input is an array containing strings. The strings are the keys whose value has to match for maps to be joined. - - -If the set of keys don't identify a map uniquely, the resulting output may contain more maps than the total sum of maps from both input arrays. ### Examples diff --git a/syntax/vm/vm_stdlib_test.go b/syntax/vm/vm_stdlib_test.go index 04802fbad2..74a0ec771c 100644 --- a/syntax/vm/vm_stdlib_test.go +++ b/syntax/vm/vm_stdlib_test.go @@ -53,6 +53,12 @@ func TestVM_Stdlib(t *testing.T) { `targets.merge([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}, {"a": "a1", "b": "b1", "c": "c1"}}, }, + { + // Non-unique merge criteria. + "targets.merge", + `targets.merge([{"pod" = "a", "lbl" = "q"}, {"pod" = "b", "lbl" = "q"}], [{"pod" = "c", "lbl" = "q"}, {"pod" = "d", "lbl" = "q"}], ["lbl"])`, + []map[string]interface{}{{"lbl": "q", "pod": "c"}, {"lbl": "q", "pod": "d"}, {"lbl": "q", "pod": "c"}, {"lbl": "q", "pod": "d"}}, + }, { // Basic case. Integer and string values. "targets.merge",