Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype targets.merge function #1826

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/sources/reference/stdlib/targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The `targets.inner_join` function allows you to join two arrays containing maps if certain keys have matching values in both maps.
The `targets.merge` function allows you to join two arrays containing maps if certain keys have matching values in both maps.

I need to rename to targets.merge here and in all other places in the doc.

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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the "third input" assumes both targets have a label with the same value. I wonder if it's worth changing the third input to be an array of arrays, where we can set different label names in the LHS and RHS targets. E.g.:

[["lhs_lbl_name"], ["rhs_lbl_name"], ["lhs_lbl_name_2"], ["rhs_lbl_name_2"]]

This could reduce the amount of relabels a user would have to do. But I don't know if it's really worth the extra complexity. The users probably have to relabel anyway.


### 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"])
```
138 changes: 138 additions & 0 deletions syntax/internal/stdlib/stdlib.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var Identifiers = map[string]interface{}{
"sys": sys,
"convert": convert,
"array": array,
"targets": targets,
"encoding": encoding,
"string": str,
"file": file,
Expand Down Expand Up @@ -89,6 +90,10 @@ var array = map[string]interface{}{
"concat": concat,
}

var targets = map[string]interface{}{
"merge": targetsMerge,
}

var convert = map[string]interface{}{
"nonsensitive": nonSensitive,
}
Expand Down Expand Up @@ -146,6 +151,139 @@ var concat = value.RawFunction(func(funcValue value.Value, args ...value.Value)
return value.Array(raw...), nil
})

// 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)
condition := c.Text()

leftVal, ok := left.Key(condition)
if !ok {
return false, fmt.Errorf("concatMaps: key %s not found in left map", condition)
}

rightVal, ok := right.Key(condition)
if !ok {
return false, fmt.Errorf("concatMaps: key %s not found in right map", condition)
}

if !leftVal.Equal(rightVal) {
return false, nil
}
}
return true, nil
}

// 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() {
val, ok := left.Key(key)
if !ok {
return value.Null, fmt.Errorf("concatMaps: key %s not found in left map", key)
}
res[key] = val
}

for _, key := range right.Keys() {
val, ok := right.Key(key)
if !ok {
return value.Null, fmt.Errorf("concatMaps: key %s not found in right map", key)
}
res[key] = val
}

return value.Object(res), nil
}

// Inputs:
// 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{
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,
},
}
}
}
}

// 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,
},
}
}
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"),
}
}

// 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)

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)
}
}
}

return value.Array(res...), nil
})

func jsonDecode(in string) (interface{}, error) {
var res interface{}
err := json.Unmarshal([]byte(in), &res)
Expand Down
18 changes: 16 additions & 2 deletions syntax/internal/value/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
17 changes: 17 additions & 0 deletions syntax/internal/value/value_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions syntax/vm/vm_stdlib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,57 @@ 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
{
// Basic case. No conflicting key/val pairs.
"targets.merge",
`targets.merge([{"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the result for invalid input? Imagine the first is a valid map list but the second is nil/invalid type/etc? I imagine it would return the map list, whereas if it is the reverse it would return a blank list?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an error if the input is in valid:

Screenshot 2024-10-31 at 18 16 42 Screenshot 2024-10-31 at 18 16 09 Screenshot 2024-10-31 at 18 17 41

[]map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}},
},
{
// 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"}},
},
{
// 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",
`targets.merge([{"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}}}},
},
}

for _, tc := range tt {
Expand All @@ -55,6 +106,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!")

Expand Down
Loading