diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 674a928..d77318e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: uses: magefile/mage-action@v1 with: version: latest - args: coverage + args: cover - name: Build backend if: steps.check-for-backend.outputs.has-backend == 'true' @@ -77,6 +77,7 @@ jobs: uses: codecov/codecov-action@v1 with: directory: ./coverage/ + files: ./coverage/lcov.info,./coverage/backend.txt env_vars: OS,PYTHON fail_ci_if_error: true path_to_write_report: ./coverage/codecov_report.txt \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d7a508e..7221619 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -67,7 +67,7 @@ jobs: uses: magefile/mage-action@v1 with: version: latest - args: coverage + args: cover - name: Build backend if: steps.check-for-backend.outputs.has-backend == 'true' diff --git a/Magefile.go b/Magefile.go index f23b1e1..4deefd8 100644 --- a/Magefile.go +++ b/Magefile.go @@ -3,14 +3,30 @@ package main import ( - "fmt" + "os" + "path/filepath" + // mage:import build "github.com/grafana/grafana-plugin-sdk-go/build" + "github.com/magefile/mage/sh" ) -// Hello prints a message (shows that you can define custom Mage targets). -func Hello() { - fmt.Println("hello plugin developer!") +// runs backend tests and makes a txt coverage report in "atomic" mode and html coverage report. +func Cover() error { + // Create a coverage file if it does not already exist + if err := os.MkdirAll(filepath.Join(".", "coverage"), os.ModePerm); err != nil { + return err + } + + if err := sh.RunV("go", "test", "./pkg/...", "-v", "-cover", "-covermode=atomic", "-coverprofile=coverage/backend.txt"); err != nil { + return err + } + + if err := sh.RunV("go", "tool", "cover", "-html=coverage/backend.txt", "-o", "coverage/backend.html"); err != nil { + return err + } + + return nil } // Default configures the default target. diff --git a/README.md b/README.md index fc6e7cd..8d72168 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ If Redis is running as Docker container on MacOS, please update host to `host.do url: redis://host.docker.internal:6379 ``` +If Redis is running as Docker container on Linux, please update host to `redis` + +``` + url: redis://redis:6379 +``` + ### Run using `docker-compose` for development Data Source have to be built following [BUILD](https://github.com/RedisGrafana/grafana-redis-datasource/blob/master/BUILD.md) instructions before starting using `docker-compose-dev.yml` file. diff --git a/data/dump.rdb b/data/dump.rdb index 7d811ef..dee5aed 100644 Binary files a/data/dump.rdb and b/data/dump.rdb differ diff --git a/go.mod b/go.mod index aca5b61..1db495e 100644 --- a/go.mod +++ b/go.mod @@ -15,12 +15,14 @@ require ( github.com/hashicorp/yamux v0.0.0-20200609203250-aecfd211c9ce // indirect github.com/jhump/protoreflect v1.8.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/magefile/mage v1.10.0 github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mediocregopher/radix/v3 v3.6.0 github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/oklog/run v1.1.0 // indirect github.com/prometheus/client_golang v1.8.0 // indirect github.com/prometheus/common v0.15.0 // indirect + github.com/stretchr/testify v1.6.1 golang.org/x/net v0.0.0-20201209123823-ac852fbbde11 // indirect golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e // indirect golang.org/x/text v0.3.4 // indirect diff --git a/go.sum b/go.sum index 7ed6032..145d77f 100644 --- a/go.sum +++ b/go.sum @@ -361,6 +361,7 @@ github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/package.json b/package.json index 03e78ea..c0891fc 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "start": "docker-compose up", "start:dev": "docker-compose -f docker-compose-dev.yml up", "test": "grafana-toolkit plugin:test --coverage", + "test:backend": "mage cover", "watch": "grafana-toolkit plugin:dev --watch" }, "version": "1.3.0" diff --git a/pkg/data-frame.go b/pkg/data-frame.go index f390a98..c03017b 100644 --- a/pkg/data-frame.go +++ b/pkg/data-frame.go @@ -41,6 +41,7 @@ func (ds *redisDatasource) addFrameFieldsFromArray(values []interface{}, frame * key = string(k) default: log.DefaultLogger.Error("addFrameFieldsFromArray", "Conversion Error", "Unsupported Key type") + continue } // Value diff --git a/pkg/data-frame_test.go b/pkg/data-frame_test.go new file mode 100644 index 0000000..4137b1f --- /dev/null +++ b/pkg/data-frame_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/require" +) + +func TestCreateFrameValue(t *testing.T) { + t.Parallel() + tests := []struct { + value string + expected interface{} + }{ + {"3.14", 3.14}, + {"3", float64(3)}, + {"somestring", "somestring"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.value, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + frame := ds.createFrameValue("keyName", tt.value) + field := frame.Fields[0].At(0) + require.Equal(t, tt.expected, field, "Unexpected conversation") + }) + } +} + +func TestAddFrameFieldsFromArray(t *testing.T) { + t.Parallel() + tests := []struct { + name string + values []interface{} + fieldsCount int + }{ + { + "should not parse key of not []byte type, and should not create field", + []interface{}{ + []interface{}{"sensor_id", []byte("2")}, + }, + 0, + }, + { + "should parse value of type bytes[] with underlying int", + []interface{}{ + []interface{}{[]byte("sensor_id"), []byte("2")}, + []interface{}{[]byte("area_id"), []byte("32")}, + }, + 2, + }, + { + "should parse value of type bytes[] with underlying non-int value", + []interface{}{ + []interface{}{[]byte("sensor_id"), []byte("some_string")}, + }, + 1, + }, + { + "should parse value of type int64", + []interface{}{ + []interface{}{[]byte("sensor_id"), int64(145)}, + }, + 1, + }, + { + "should not parse value of not bytes[] or int64", + []interface{}{ + []interface{}{[]byte("sensor_id"), float32(3.14)}, + }, + 0, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + frame := data.NewFrame("name") + frame = ds.addFrameFieldsFromArray(tt.values, frame) + require.Len(t, frame.Fields, tt.fieldsCount, "Invalid number of fields created in Frame") + }) + } +} diff --git a/pkg/query_test.go b/pkg/query_test.go new file mode 100644 index 0000000..ea8fcf3 --- /dev/null +++ b/pkg/query_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/mediocregopher/radix/v3/resp/resp2" + "github.com/stretchr/testify/require" +) + +func TestQuery(t *testing.T) { + t.Parallel() + tests := []struct { + qm queryModel + }{ + {queryModel{Command: "ts.get"}}, + {queryModel{Command: "ts.info"}}, + {queryModel{Command: "ts.queryindex"}}, + {queryModel{Command: "ts.range"}}, + {queryModel{Command: "ts.mrange"}}, + {queryModel{Command: "hgetall"}}, + {queryModel{Command: "smembers"}}, + {queryModel{Command: "hkeys"}}, + {queryModel{Command: "hget"}}, + {queryModel{Command: "hmget"}}, + {queryModel{Command: "info"}}, + {queryModel{Command: "clientList"}}, + {queryModel{Command: "slowlogGet"}}, + {queryModel{Command: "type"}}, + {queryModel{Command: "xinfoStream"}}, + {queryModel{Command: "clusterInfo"}}, + {queryModel{Command: "clusterNodes"}}, + {queryModel{Command: "ft.info"}}, + {queryModel{Command: "xinfoStream"}}, + {queryModel{Query: "DO something"}}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.qm.Command, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{nil, nil} + var marshaled, _ = json.Marshal(tt.qm) + response := ds.query(context.TODO(), backend.DataQuery{ + RefID: "", + QueryType: "", + MaxDataPoints: 100, + Interval: 10, + TimeRange: backend.TimeRange{From: time.Now(), To: time.Now()}, + JSON: marshaled, + }, client) + require.NoError(t, response.Error, "Should not return error") + }) + } +} + +func TestQueryWithErrors(t *testing.T) { + t.Parallel() + + t.Run("Marshalling failure", func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{nil, nil} + response := ds.query(context.TODO(), backend.DataQuery{ + RefID: "", + QueryType: "", + MaxDataPoints: 100, + Interval: 10, + TimeRange: backend.TimeRange{From: time.Now(), To: time.Now()}, + JSON: []byte{31, 17, 45}, + }, client) + + require.EqualError(t, response.Error, "invalid character '\\x1f' looking for beginning of value", "Should return marshalling error") + }) + + t.Run("Unknown command failure", func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{nil, nil} + var marshaled, _ = json.Marshal(queryModel{Command: "unknown"}) + response := ds.query(context.TODO(), backend.DataQuery{ + RefID: "", + QueryType: "", + MaxDataPoints: 100, + Interval: 10, + TimeRange: backend.TimeRange{From: time.Now(), To: time.Now()}, + JSON: marshaled, + }, client) + + require.EqualError(t, response.Error, "Unknown command", "Should return unknown command error") + }) + +} + +func TestErrorHandler(t *testing.T) { + t.Parallel() + + t.Run("Common error", func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + resp := ds.errorHandler(backend.DataResponse{}, errors.New("common error")) + require.EqualError(t, resp.Error, "common error", "Should return marshalling error") + }) + + t.Run("Redis error", func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + resp := ds.errorHandler(backend.DataResponse{}, resp2.Error{E: errors.New("redis error")}) + require.EqualError(t, resp.Error, "redis error", "Should return marshalling error") + }) + +} + +func TestQueryKeyCommand(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should handle string value", + queryModel{Command: "get", Key: "test1"}, + "someStr", + 1, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "someStr"}, + }, + nil, + }, + { + "should handle float64 value", + queryModel{Command: "get", Key: "test1"}, + "3.14", + 1, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: 3.14}, + }, + nil, + }, + { + "should handle error", + queryModel{Command: "get"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryKeyCommand(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} diff --git a/pkg/redis-cluster_test.go b/pkg/redis-cluster_test.go new file mode 100644 index 0000000..6d43853 --- /dev/null +++ b/pkg/redis-cluster_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQueryClusterInfo(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should parse clusterInfo bulk string", + queryModel{Command: "clusterInfo"}, + "cluster_state:ok\r\ncluster_slots_assigned:16384\r\ncluster_slots_ok:16384\r\ncluster_slots_pfail:0\r\ncluster_slots_fail:0\r\ncluster_known_nodes:6\r\ncluster_size:3\r\ncluster_current_epoch:6\r\ncluster_my_epoch:2\r\ncluster_stats_messages_sent:1483972\r\ncluster_stats_messages_received:1483968", + 11, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "ok"}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(16384)}, + {frameIndex: 0, fieldIndex: 6, rowIndex: 0, value: float64(3)}, + }, + nil, + }, + { + "should parse string and ignore non-pairing param", + queryModel{Command: "clusterInfo"}, + "cluster_state:ok\r\ncluster_slots_assigned\r\ncluster_slots_ok:16384\r\ncluster_slots_pfail:0\r\ncluster_slots_fail:0\r\ncluster_known_nodes:6\r\ncluster_size:3\r\ncluster_current_epoch:6\r\ncluster_my_epoch:2\r\ncluster_stats_messages_sent:1483972\r\ncluster_stats_messages_received:1483968", + 10, + 1, + nil, + nil, + }, + { + "should handle error", + queryModel{Command: "info"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryClusterInfo(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQueryClusterNodes(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should parse clusterNodes bulk string", + queryModel{Command: "clusterNodes"}, + "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004 slave e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 1609783649927 1426238317239 4 connected\r\n67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 127.0.0.1:30002@31002 master - 0 1426238316232 2 connected 5461-10922\r\n292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 127.0.0.1:30003@31003 master - 0 1426238318243 3 connected 10923-16383\r\n6ec23923021cf3ffec47632106199cb7f496ce01 127.0.0.1:30005@31005 slave 67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 0 1426238316232 5 connected\r\n824fe116063bc5fcf9f4ffd895bc17aee7731ac3 127.0.0.1:30006@31006 slave 292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 0 1426238317741 6 connected\r\ne7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-5460", + 9, + 6, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 2, value: "292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f"}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 3, value: "127.0.0.1:30005@31005"}, + {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: "slave"}, + {frameIndex: 0, fieldIndex: 4, rowIndex: 0, value: int64(1609783649927)}, + {frameIndex: 0, fieldIndex: 6, rowIndex: 3, value: int64(5)}, + {frameIndex: 0, fieldIndex: 8, rowIndex: 2, value: "10923-16383"}, + }, + nil, + }, + { + "should handle string with invalid number of values", + queryModel{Command: "clusterNodes"}, + "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004 e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 1609783649927 1426238317239 4 connected", + 9, + 0, + nil, + nil, + }, + { + "should handle error", + queryModel{Command: "clusterNodes"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryClusterNodes(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} diff --git a/pkg/redis-custom_test.go b/pkg/redis-custom_test.go new file mode 100644 index 0000000..0be7498 --- /dev/null +++ b/pkg/redis-custom_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "errors" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" +) + +func TestExecuteCustomQuery(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + err error + }{ + { + "should parse correct real-world command with params", + queryModel{Query: "config get *max-*-entries*"}, + []interface{}{ + []byte("hash-max-ziplist-entries"), + []byte("512"), + []byte("set-max-intset-entries"), + []byte("512"), + []byte("zset-max-ziplist-entries"), + []byte("128"), + }, + nil, + }, + { + "should parse correct real-world command without params", + queryModel{Query: "lastsave"}, + int64(1609840612), + nil, + }, + { + "should handle error if invalid command string", + queryModel{Query: "lastsave \""}, + nil, + errors.New("Query is not valid"), + }, + { + "should handle error", + queryModel{Query: "lastsave"}, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + result, err := ds.executeCustomQuery(tt.qm, client) + if tt.err != nil { + require.EqualError(t, err, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, result, "No result should be created if failed") + } else { + require.Equal(t, tt.rcv, result, "Should return receiver value") + } + }) + } +} + +func TestExecuteCustomQueryWithPanic(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := panickingClient{} + result, err := ds.executeCustomQuery(queryModel{Query: "panic"}, client) + require.NoError(t, err, "Should return error") + require.Nil(t, result, "No result if panicked") +} + +func TestParseInterfaceValue(t *testing.T) { + t.Parallel() + t.Run("should parse complex input", func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + inputResponse := backend.DataResponse{} + input := []interface{}{ + "str", + []byte("str2"), + int64(42), + []interface{}{}, + []interface{}{ + "str", + []byte("str3"), + int64(66), + }, + } + + expected := []string{"str", "str2", "42", "(empty array)", "str", "str3", "66"} + result, response := ds.parseInterfaceValue(input, inputResponse) + require.NoError(t, response.Error, "Should return error") + require.Equal(t, expected, result, "Invalid function return value") + + }) + t.Run("should fail on unsupported type", func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + inputResponse := backend.DataResponse{} + input := []interface{}{ + "str", + []byte("str2"), + int64(42), + 3.14, + []interface{}{}, + []interface{}{ + "str", + []byte("str2"), + int64(42), + 3.14, + }, + } + + expected := []string{"str", "str2", "42"} + result, response := ds.parseInterfaceValue(input, inputResponse) + require.EqualError(t, response.Error, "Unsupported array return type", "Should return error") + require.Equal(t, expected, result, "Should contain results before unsupported parameter") + }) + +} + +func TestQueryCustomCommand(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + errToCheck string + }{ + { + "should handle empty interface array without values", + queryModel{Query: "test"}, + []interface{}{}, + 1, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "(empty array)"}, + }, + nil, + "", + }, + { + "should handle empty interface array with nesting", + queryModel{Query: "test"}, + []interface{}{ + "str", + []byte("str2"), + int64(42), + []interface{}{}, + []interface{}{ + "str", + []byte("str3"), + int64(66), + }, + }, + 1, + 7, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 6, value: "66"}, + }, + nil, + "", + }, + { + "should handle string", + queryModel{Query: "test"}, + "str", + 1, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "str"}, + }, + nil, + "", + }, + { + "should handle []byte with single string inside", + queryModel{Query: "test"}, + []byte("str"), + 1, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "str"}, + }, + nil, + "", + }, + { + "should handle []byte with bulk string inside", + queryModel{Query: "test"}, + []byte("str\r\nstr2"), + 1, + 2, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "str"}, + {frameIndex: 0, fieldIndex: 0, rowIndex: 1, value: "str2"}, + }, + nil, + "", + }, + { + "should handle int64 and return field as int64", + queryModel{Query: "test"}, + int64(42), + 1, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: int64(42)}, + }, + nil, + "", + }, + { + "should fail with emtpy command", + queryModel{Query: ""}, + nil, + 0, + 0, + nil, + nil, + "Command is empty", + }, + { + "should handle error", + queryModel{Query: "test"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + "error occurred", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryCustomCommand(tt.qm, client) + if tt.errToCheck != "" { + require.EqualError(t, response.Error, tt.errToCheck, "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + }) + } +} diff --git a/pkg/redis-hash_test.go b/pkg/redis-hash_test.go new file mode 100644 index 0000000..6bd253d --- /dev/null +++ b/pkg/redis-hash_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQueryHGetAll(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should handle default array of strings", + queryModel{Command: "hgetall", Key: "test1"}, + []string{"key1", "value1", "key2", "2", "key3", "3.14"}, + 3, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "value1"}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(2)}, + {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: 3.14}, + }, + nil, + }, + { + "should handle error", + queryModel{Command: "hgetall"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryHGetAll(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQueryHGet(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + value interface{} + err error + }{ + { + "should handle simple string", + queryModel{Command: "hget", Key: "test1", Field: "field1"}, + "value1", + 1, + 1, + "value1", + nil, + }, + { + "should handle string with underlying float64 value", + queryModel{Command: "hget", Key: "test1", Field: "key1"}, + "3.14", + 1, + 1, + 3.14, + nil, + }, + { + "should handle error", + queryModel{Command: "hget"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryHGet(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Field, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + require.Equal(t, tt.value, response.Frames[0].Fields[0].At(0), "Invalid value contained in frame") + + } + }) + } +} + +func TestQueryHMGet(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + shouldCreateFrames bool + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should handle 3 fields with different underlying types", + queryModel{Command: "hmget", Key: "test1", Field: "field1 field2 field3"}, + []string{"value1", "2", "3.14"}, + 3, + 1, + true, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "value1"}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(2)}, + {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: 3.14}, + }, + nil, + }, + { + "should handle Field string parsing error and create no fields", + queryModel{Command: "hmget", Key: "test1", Field: "field1 field2\"field3"}, + nil, + 0, + 0, + false, + nil, + nil, + }, + { + "should handle error", + queryModel{Command: "hmget"}, + nil, + 0, + 0, + false, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryHMGet(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + if tt.shouldCreateFrames { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } else { + require.Nil(t, response.Frames, "Should not create frames in response") + } + } + }) + } +} diff --git a/pkg/redis-info.go b/pkg/redis-info.go index 96f18a1..1835374 100644 --- a/pkg/redis-info.go +++ b/pkg/redis-info.go @@ -160,6 +160,11 @@ func (ds *redisDatasource) queryClientList(qm queryModel, client ClientInterface // Split properties value := strings.Split(field, "=") + // Skip if less than 2 elements + if len(value) < 2 { + continue + } + // Add Header for first row if i == 0 { if _, err := strconv.ParseInt(value[1], 10, 64); err == nil { @@ -169,11 +174,6 @@ func (ds *redisDatasource) queryClientList(qm queryModel, client ClientInterface } } - // Skip if less than 2 elements - if len(value) < 2 { - continue - } - // Add Int64 or String value if intValue, err := strconv.ParseInt(value[1], 10, 64); err == nil { values = append(values, intValue) diff --git a/pkg/redis-info_test.go b/pkg/redis-info_test.go new file mode 100644 index 0000000..89a4f5b --- /dev/null +++ b/pkg/redis-info_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestQueryInfo(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should parse default bulk string", + queryModel{Command: "info"}, + "# Server\r\nredis_version:6.0.1\r\nredis_git_sha1:00000000\r\nredis_git_dirty:0\r\nredis_build_id:e02d1d807e41d65\r\nredis_mode:standalone\r\nos:Linux 5.4.0-58-generic x86_64\r\narch_bits:64\r\nmultiplexing_api:epoll\r\natomicvar_api:atomic-builtin\r\ngcc_version:8.3.0\r\nprocess_id:1\r\nrun_id:63886ab60ce4a06f9b5c2101dd08b0a1c3099a64\r\ntcp_port:6379\r\nuptime_in_seconds:224\r\nuptime_in_days:0\r\nhz:10\r\nconfigured_hz:10\r\nlru_clock:15846554\r\nexecutable:/data/redis-server\r\nconfig_file:\r\n\r\n# Clients\r\nconnected_clients:2\r\nclient_recent_max_input_buffer:0\r\nclient_recent_max_output_buffer:0\r\nblocked_clients:0\r\ntracking_clients:0\r\nclients_in_timeout_table:0\r\n\r\n# Memory\r\nused_memory:5377000\r\nused_memory_human:5.13M\r\nused_memory_rss:22200320\r\nused_memory_rss_human:21.17M\r\nused_memory_peak:5377000\r\nused_memory_peak_human:5.13M\r\nused_memory_peak_perc:101.60%\r\nused_memory_overhead:5292568\r\nused_memory_startup:5292568\r\nused_memory_dataset:84432\r\nused_memory_dataset_perc:100.00%\r\nallocator_allocated:5588768\r\nallocator_active:5984256\r\nallocator_resident:8859648\r\ntotal_system_memory:33509154816\r\ntotal_system_memory_human:31.21G\r\nused_memory_lua:37888\r\nused_memory_lua_human:37.00K\r\nused_memory_scripts:0\r\nused_memory_scripts_human:0B\r\nnumber_of_cached_scripts:0\r\nmaxmemory:0\r\nmaxmemory_human:0B\r\nmaxmemory_policy:noeviction\r\nallocator_frag_ratio:1.07\r\nallocator_frag_bytes:395488\r\nallocator_rss_ratio:1.48\r\nallocator_rss_bytes:2875392\r\nrss_overhead_ratio:2.51\r\nrss_overhead_bytes:13340672\r\nmem_fragmentation_ratio:4.19\r\nmem_fragmentation_bytes:16907752\r\nmem_not_counted_for_evict:0\r\nmem_replication_backlog:0\r\nmem_clients_slaves:0\r\nmem_clients_normal:0\r\nmem_aof_buffer:0\r\nmem_allocator:jemalloc-5.1.0\r\nactive_defrag_running:0\r\nlazyfree_pending_objects:0\r\n\r\n# Persistence\r\nloading:0\r\nrdb_changes_since_last_save:0\r\nrdb_bgsave_in_progress:0\r\nrdb_last_save_time:1609681850\r\nrdb_last_bgsave_status:ok\r\nrdb_last_bgsave_time_sec:-1\r\nrdb_current_bgsave_time_sec:-1\r\nrdb_last_cow_size:0\r\naof_enabled:0\r\naof_rewrite_in_progress:0\r\naof_rewrite_scheduled:0\r\naof_last_rewrite_time_sec:-1\r\naof_current_rewrite_time_sec:-1\r\naof_last_bgrewrite_status:ok\r\naof_last_write_status:ok\r\naof_last_cow_size:0\r\nmodule_fork_in_progress:0\r\nmodule_fork_last_cow_size:0\r\n\r\n# Stats\r\ntotal_connections_received:2\r\ntotal_commands_processed:5\r\ninstantaneous_ops_per_sec:0\r\ntotal_net_input_bytes:14\r\ntotal_net_output_bytes:0\r\ninstantaneous_input_kbps:0.00\r\ninstantaneous_output_kbps:0.00\r\nrejected_connections:0\r\nsync_full:0\r\nsync_partial_ok:0\r\nsync_partial_err:0\r\nexpired_keys:0\r\nexpired_stale_perc:0.00\r\nexpired_time_cap_reached_count:0\r\nexpire_cycle_cpu_milliseconds:6\r\nevicted_keys:0\r\nkeyspace_hits:0\r\nkeyspace_misses:0\r\npubsub_channels:0\r\npubsub_patterns:0\r\nlatest_fork_usec:0\r\nmigrate_cached_sockets:0\r\nslave_expires_tracked_keys:0\r\nactive_defrag_hits:0\r\nactive_defrag_misses:0\r\nactive_defrag_key_hits:0\r\nactive_defrag_key_misses:0\r\ntracking_total_keys:0\r\ntracking_total_items:0\r\nunexpected_error_replies:0\r\n\r\n# Replication\r\nrole:master\r\nconnected_slaves:0\r\nmaster_replid:9a0cb33c79e465dad8b2468c958a523d82f6b572\r\nmaster_replid2:0000000000000000000000000000000000000000\r\nmaster_repl_offset:0\r\nmaster_repl_meaningful_offset:0\r\nsecond_repl_offset:-1\r\nrepl_backlog_active:0\r\nrepl_backlog_size:1048576\r\nrepl_backlog_first_byte_offset:0\r\nrepl_backlog_histlen:0\r\n\r\n# CPU\r\nused_cpu_sys:0.393216\r\nused_cpu_user:0.403734\r\nused_cpu_sys_children:0.000000\r\nused_cpu_user_children:0.000000\r\n\r\n# Modules\r\nmodule:name=ReJSON,ver=10007,api=1,filters=0,usedby=[],using=[],options=[]\r\nmodule:name=search,ver=20005,api=1,filters=0,usedby=[],using=[],options=[]\r\nmodule:name=graph,ver=20212,api=1,filters=0,usedby=[],using=[],options=[]\r\nmodule:name=rg,ver=10003,api=1,filters=0,usedby=[],using=[ai],options=[]\r\nmodule:name=timeseries,ver=10407,api=1,filters=0,usedby=[],using=[],options=[]\r\nmodule:name=bf,ver=20205,api=1,filters=0,usedby=[],using=[],options=[]\r\nmodule:name=ai,ver=10002,api=1,filters=0,usedby=[rg],using=[],options=[]\r\n\r\n# Cluster\r\ncluster_enabled:0\r\n\r\n# Keyspace\r\n", + 137, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "6.0.1"}, + {frameIndex: 0, fieldIndex: 6, rowIndex: 0, value: float64(64)}, + {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: float64(0)}, + }, + nil, + }, + { + "should parse bulk string with 'commandstats' section", + queryModel{Command: "info", Section: "commandstats"}, + "# Commandstats\r\ncmdstat_info:calls=5,usec=203,usec_per_call=40.60\r\ncmdstat_config:calls=1,usec=29,usec_per_call=29.00\r\n", + 4, + 2, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "info"}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: int64(5)}, + {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: float64(203)}, + {frameIndex: 0, fieldIndex: 3, rowIndex: 0, value: float64(40.60)}, + }, + nil, + }, + { + "should parse bulk string with 'commandstats' and ignore stats if only 2 stats per command", + queryModel{Command: "info", Section: "commandstats"}, + "# Commandstats\r\ncmdstat_info:calls=5,usec_per_call=40.60\r\ncmdstat_config:calls=1,usec_per_call=29.00\r\n", + 4, + 0, + nil, + nil, + }, + { + "should parse bulk string with 'commandstats' section ans streaming true", + queryModel{Command: "info", Section: "commandstats", Streaming: true}, + "# Commandstats\r\ncmdstat_info:calls=5,usec=203,usec_per_call=40.60\r\ncmdstat_config:calls=1,usec=29,usec_per_call=29.00\r\n", + 2, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: int64(5)}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: int64(1)}, + }, + nil, + }, + { + "should handle error", + queryModel{Command: "info"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryInfo(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQueryClientList(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should parse default bulk string", + queryModel{Command: "clientList"}, + "id=81 addr=172.18.0.1:33504 fd=13 name= age=0 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client user=default\nid=82 addr=172.18.0.1:33508 fd=14 name= age=0 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=NULL user=default\n", + 19, + 2, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: int64(81)}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: "172.18.0.1:33504"}, + }, + nil, + }, + { + "should parse default bulk string and ignore elements without the =", + queryModel{Command: "clientList"}, + "id=81 dummy addr=172.18.0.1:33504 fd=13 name= age=0 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client user=default\nid=82 dummy addr=172.18.0.1:33508 fd=14 name= age=0 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=NULL user=default\n", + 19, + 2, + nil, + nil, + }, + { + "should handle error", + queryModel{Command: "clientList"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryClientList(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQuerySlowlogGet(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should parse payload for redis prior to version 4.0", + queryModel{Command: "slowlogGet"}, + []interface{}{ + []interface{}{int64(14), int64(1309448221), int64(15), []interface{}{"ping"}}, + []interface{}{int64(13), int64(1309448128), int64(30), []interface{}{"slowlog", "get", "100"}}, + }, + 4, + 2, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: int64(14)}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: time.Unix(1309448221, 0)}, + {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: int64(15)}, + {frameIndex: 0, fieldIndex: 3, rowIndex: 1, value: "slowlog get 100"}, + }, + nil, + }, + { + "should parse payload for redis starting version 4.0", + queryModel{Command: "slowlogGet"}, + []interface{}{ + []interface{}{int64(14), int64(1309448221), int64(15), []interface{}{"ping"}, "127.0.0.1:58217", "worker-123"}, + []interface{}{int64(13), int64(1309448128), int64(30), []interface{}{"slowlog", "get", "100"}, "127.0.0.1:58217", "worker-123"}, + }, + 4, + 2, + nil, + nil, + }, + { + "should parse payload with array of command arguments having specific types", + queryModel{Command: "slowlogGet"}, + []interface{}{ + []interface{}{int64(14), int64(1309448221), int64(15), []interface{}{"ping", int32(3), []byte("pong"), []interface{}{}}, "127.0.0.1:58217", "worker-123"}, + }, + 4, + 1, + nil, + nil, + }, + { + "should parse payload with size provided", + queryModel{Command: "slowlogGet", Size: 2}, + []interface{}{ + []interface{}{int64(14), int64(1309448221), int64(15), []interface{}{"ping"}, "127.0.0.1:58217", "worker-123"}, + }, + 4, + 1, + nil, + nil, + }, + { + "should parse payload for redis Enterprise (command is in 5th field)", + queryModel{Command: "slowlogGet"}, + []interface{}{ + []interface{}{int64(14), int64(1309448221), int64(15), "N:886,M:885", []interface{}{"ping"}}, + []interface{}{int64(13), int64(1309448128), int64(30), "N:886,M:885", []interface{}{"slowlog", "get", "100"}}, + }, + 4, + 2, + nil, + nil, + }, + { + "should handle error", + queryModel{Command: "slowlogGet"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.querySlowlogGet(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} diff --git a/pkg/redis-search.go b/pkg/redis-search.go index 7cae581..4be20e7 100644 --- a/pkg/redis-search.go +++ b/pkg/redis-search.go @@ -18,7 +18,7 @@ func (ds *redisDatasource) queryFtInfo(qm queryModel, client ClientInterface) ba response := backend.DataResponse{} // Execute command - var result interface{} + var result map[string]interface{} err := client.Do(radix.Cmd(&result, qm.Command, qm.Key)) // Check error @@ -30,27 +30,17 @@ func (ds *redisDatasource) queryFtInfo(qm queryModel, client ClientInterface) ba frame := data.NewFrame(qm.Key) // Add fields and values - for i := 0; i < len(result.([]interface{})); i += 2 { - - // Parameter - var param string - switch value := result.([]interface{})[i].(type) { - case string: - param = value - default: - log.DefaultLogger.Error("queryTsInfo", "Conversion Error", "Unsupported Key type") - } - + for key := range result { // Value - switch value := result.([]interface{})[i+1].(type) { + switch value := result[key].(type) { case int64: // Add field - field := data.NewField(param, nil, []int64{value}) + field := data.NewField(key, nil, []int64{value}) frame.Fields = append(frame.Fields, field) case []byte: // Parse Float if floatValue, err := strconv.ParseFloat(string(value), 64); err == nil { - field := data.NewField(param, nil, []float64{floatValue}) + field := data.NewField(key, nil, []float64{floatValue}) // Field Units config := map[string]string{"inverted_sz_mb": "decmbytes", "offset_vectors_sz_mb": "decmbytes", @@ -58,16 +48,16 @@ func (ds *redisDatasource) queryFtInfo(qm queryModel, client ClientInterface) ba "key_table_size_mb": "decmbytes", "percent_indexed": "percentunit"} // Set unit - if config[param] != "" { - field.Config = &data.FieldConfig{Unit: config[param]} + if config[key] != "" { + field.Config = &data.FieldConfig{Unit: config[key]} } frame.Fields = append(frame.Fields, field) } else { - frame.Fields = append(frame.Fields, data.NewField(param, nil, []string{string(value)})) + frame.Fields = append(frame.Fields, data.NewField(key, nil, []string{string(value)})) } case string: - frame.Fields = append(frame.Fields, data.NewField(param, nil, []string{string(value)})) + frame.Fields = append(frame.Fields, data.NewField(key, nil, []string{string(value)})) case []interface{}: default: log.DefaultLogger.Error("queryTsInfo", "Conversion Error", "Unsupported Value type") diff --git a/pkg/redis-search_test.go b/pkg/redis-search_test.go new file mode 100644 index 0000000..b3fa3d1 --- /dev/null +++ b/pkg/redis-search_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQueryFtInfo(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valueToCheckByLabelInResponse []valueToCheckByLabelInResponse + err error + }{ + { + "should parse default bulk string", + queryModel{Command: "ft.info", Key: "wik{0}"}, + []interface{}{ + "index_name", + "wikipedia", + "index_options", + []interface{}{}, + "index_definition", + []interface{}{ + "key_type", + "HASH", + "prefixes", + []interface{}{"thing"}, + "filter", + "startswith(@__key, \"thing:\")", + "language_field", + "__language", + "default_score", + "1", + "score_field", + "__score", + "payload_field", + "__payload", + }, + "fields", + []interface{}{ + []interface{}{ + "title", + "type", + "TEXT", + "WEIGHT", + "1", + "SORTABLE", + }, + []interface{}{ + "body", + "type", + "TEXT", + "WEIGHT", + }, + []interface{}{ + "id", + "type", + "NUMERIC", + }, + []interface{}{ + "subject location", + "type", + "GEO", + }, + }, + "num_docs", + "0", + "max_doc_id", + "345678", + "num_terms", + "691356", + "num_records", + "0", + "inverted_sz_mb", + int64(0), + "total_inverted_index_blocks", + "933290", + "offset_vectors_sz_mb", + "0.65932846069335938", + "doc_table_size_mb", + "29.893482208251953", + "sortable_values_size_mb", + "11.432285308837891", + "key_table_size_mb", + "1.239776611328125e-05", + "records_per_doc_avg", + "-nan", + "bytes_per_record_avg", + "-nan", + "offsets_per_term_avg", + "inf", + "offset_bits_per_record_avg", + "8", + "hash_indexing_failures", + "0", + "indexing", + "0", + "percent_indexed", + "1", + "gc_stats", + []interface{}{ + "bytes_collected", + "4148136", + "total_ms_run", + "14796", + "total_cycles", + "1", + "average_cycle_time_ms", + "14796", + "last_run_time_ms", + "14796", + "gc_numeric_trees_missed", + "0", + "gc_blocks_denied", + "0", + }, + "cursor_stats", + []interface{}{ + "global_idle", + int64(0), + "global_total", + int64(0), + "index_capacity", + int64(128), + "index_total", + int64(0), + }, + "stopwords_list", + []interface{}{ + "tlv", + "summer", + "2020", + }, + }, + 18, + 1, + []valueToCheckByLabelInResponse{ + {frameIndex: 0, fieldName: "index_name", rowIndex: 0, value: "wikipedia"}, + {frameIndex: 0, fieldName: "num_terms", rowIndex: 0, value: float64(691356)}, + {frameIndex: 0, fieldName: "offset_vectors_sz_mb", rowIndex: 0, value: float64(0.6593284606933594)}, + }, + nil, + }, + { + "should handle error", + queryModel{Command: "info"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryFtInfo(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valueToCheckByLabelInResponse != nil { + for _, value := range tt.valueToCheckByLabelInResponse { + for _, field := range response.Frames[value.frameIndex].Fields { + if field.Name == value.fieldName { + require.Equalf(t, value.value, field.At(value.rowIndex), "Invalid value at Frame[%v]:Field[Name:%v]:Row[%v]", value.frameIndex, value.fieldName, value.rowIndex) + } + } + + } + } + } + }) + } +} diff --git a/pkg/redis-set_test.go b/pkg/redis-set_test.go new file mode 100644 index 0000000..e7efa56 --- /dev/null +++ b/pkg/redis-set_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQuerySMembers(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should handle default array of strings", + queryModel{Command: "smembers", Key: "test1"}, + []string{"value1", "2", "3.14"}, + 1, + 3, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "value1"}, + {frameIndex: 0, fieldIndex: 0, rowIndex: 1, value: "2"}, + {frameIndex: 0, fieldIndex: 0, rowIndex: 2, value: "3.14"}, + }, + nil, + }, + { + "should handle error", + queryModel{Command: "smembers"}, + nil, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.querySMembers(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} diff --git a/pkg/redis-stream.go b/pkg/redis-stream.go index 1c75e14..4d1784d 100644 --- a/pkg/redis-stream.go +++ b/pkg/redis-stream.go @@ -15,7 +15,7 @@ func (ds *redisDatasource) queryXInfoStream(qm queryModel, client ClientInterfac response := backend.DataResponse{} // Execute command - var result []string + var result map[string]string err := client.Do(radix.FlatCmd(&result, "XINFO", "STREAM", qm.Key)) // Check error @@ -27,9 +27,9 @@ func (ds *redisDatasource) queryXInfoStream(qm queryModel, client ClientInterfac values := []string{} // Add fields and values - for i := 0; i < len(result); i += 2 { - fields = append(fields, result[i]) - values = append(values, result[i+1]) + for k := range result { + fields = append(fields, k) + values = append(values, result[k]) } // New Frame diff --git a/pkg/redis-stream_test.go b/pkg/redis-stream_test.go new file mode 100644 index 0000000..d75ad8d --- /dev/null +++ b/pkg/redis-stream_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQueryXInfoStream(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + err error + }{ + { + "should handle default payload, but collect only top-level key-value pairs", + queryModel{Command: "xinfoStream", Key: "test1"}, + []interface{}{ + "length", + 2, + "radix-tree-keys", + 1, + "radix-tree-nodes", + 2, + "groups", + 2, + "last-generated-id", + "1538385846314-0", + "first-entry", + []interface{}{ + "1538385820729-0", + []interface{}{ + "foo", + "bar", + }, + }, + "last-entry", + []interface{}{ + "1538385846314-0", + []interface{}{ + "field", + "value", + }, + }, + }, + 2, + 5, + nil, + }, + { + "should handle error", + queryModel{Command: "xinfoStream"}, + nil, + 0, + 0, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryXInfoStream(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + } + }) + } +} diff --git a/pkg/redis-time-series.go b/pkg/redis-time-series.go index 319ca59..0abf795 100644 --- a/pkg/redis-time-series.go +++ b/pkg/redis-time-series.go @@ -254,7 +254,7 @@ func (ds *redisDatasource) queryTsInfo(qm queryModel, client ClientInterface) ba response := backend.DataResponse{} // Execute command - var result interface{} + var result map[string]interface{} err := client.Do(radix.Cmd(&result, qm.Command, qm.Key)) // Check error @@ -266,46 +266,35 @@ func (ds *redisDatasource) queryTsInfo(qm queryModel, client ClientInterface) ba frame := data.NewFrame(qm.Key) // Add fields and values - for i := 0; i < len(result.([]interface{})); i += 2 { - - // Parameter - var param string - switch value := result.([]interface{})[i].(type) { - case string: - param = value - default: - log.DefaultLogger.Error("queryTsInfo", "Conversion Error", "Unsupported Key type") - } - + for key := range result { // Value - switch value := result.([]interface{})[i+1].(type) { + switch value := result[key].(type) { case int64: // Return timestamp as time - if param == "firstTimestamp" || param == "lastTimestamp" { - frame.Fields = append(frame.Fields, data.NewField(param, nil, []time.Time{time.Unix(0, value*int64(time.Millisecond))})) + if key == "firstTimestamp" || key == "lastTimestamp" { + frame.Fields = append(frame.Fields, data.NewField(key, nil, []time.Time{time.Unix(0, value*int64(time.Millisecond))})) break } // Add field - field := data.NewField(param, nil, []int64{value}) + field := data.NewField(key, nil, []int64{value}) // Set unit - if param == "memoryUsage" { + if key == "memoryUsage" { field.Config = &data.FieldConfig{Unit: "decbytes"} - } else if param == "retentionTime" { + } else if key == "retentionTime" { field.Config = &data.FieldConfig{Unit: "ms"} } frame.Fields = append(frame.Fields, field) case []byte: - frame.Fields = append(frame.Fields, data.NewField(param, nil, []string{string(value)})) + frame.Fields = append(frame.Fields, data.NewField(key, nil, []string{string(value)})) case []interface{}: frame = ds.addFrameFieldsFromArray(value, frame) default: log.DefaultLogger.Error("queryTsInfo", "Conversion Error", "Unsupported Value type") } } - // Add the frame to the response response.Frames = append(response.Frames, frame) diff --git a/pkg/redis-time-series_test.go b/pkg/redis-time-series_test.go new file mode 100644 index 0000000..4143327 --- /dev/null +++ b/pkg/redis-time-series_test.go @@ -0,0 +1,582 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestQueryTsRange(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + from int64 + to int64 + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should process receiver without aggregation and legend provided", + queryModel{Command: "ts.range", Key: "test1"}, + []interface{}{ + []interface{}{1548149180000, "26.199999999999999"}, + []interface{}{1548149195000, "27.399999999999999"}, + []interface{}{1548149220000, "24.800000000000001"}, + []interface{}{1548149215000, "23.199999999999999"}, + []interface{}{1548149230000, "25.199999999999999"}, + []interface{}{1548149285000, "28"}, + []interface{}{1548149150000, "20"}, + }, + 0, + 0, + 2, + 7, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + nil, + }, + { + "should process receiver with aggregation and legend", + queryModel{Command: "ts.range", Aggregation: "avg", Bucket: 5000, Key: "test1", Legend: "Legend"}, + []interface{}{ + []interface{}{1548149180000, "26.199999999999999"}, + []interface{}{1548149185000, "27.399999999999999"}, + []interface{}{1548149190000, "24.800000000000001"}, + []interface{}{1548149195000, "23.199999999999999"}, + []interface{}{1548149200000, "25.199999999999999"}, + []interface{}{1548149205000, "28"}, + []interface{}{1548149210000, "20"}, + }, + 0, + 0, + 2, + 7, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + nil, + }, + { + "should process receiver with fill", + queryModel{Command: "ts.range", Bucket: 5000, Key: "test1", Fill: true}, + []interface{}{ + []interface{}{1548149180000, "26.199999999999999"}, + []interface{}{1548149195000, "27.399999999999999"}, + []interface{}{1548149220000, "24.800000000000001"}, + []interface{}{1548149215000, "23.199999999999999"}, + []interface{}{1548149230000, "25.199999999999999"}, + []interface{}{1548149285000, "28"}, + []interface{}{1548149150000, "20"}, + }, + 0, + 0, + 2, + 25, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 1, value: time.Unix(0, (1548149180000+5000)*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 1, value: float64(0)}, + }, + nil, + }, + { + "should process receiver error", + queryModel{Command: "ts.range", Key: "test1"}, + nil, + 0, + 0, + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryTsRange(tt.from, tt.to, tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + if tt.qm.Legend != "" { + require.Equal(t, tt.qm.Legend, response.Frames[0].Name, "Invalid frame name") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + } + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQueryTsMRange(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + from int64 + to int64 + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + expectedFrameName string + expectedValueFieldName string + expectedError string + err error + }{ + { + "should process receiver without aggregation and legend provided but with labels", + queryModel{Command: "ts.mrange", Key: "test1", Filter: "area_id=32 sensor_id!=1"}, + []interface{}{ + []interface{}{ + "temperature:2:32", + [][]string{{"sensor_id", "2"}, {"area_id", "32"}}, + []interface{}{ + []interface{}{1548149180000, "26.199999999999999"}, + []interface{}{1548149195000, "27.399999999999999"}, + []interface{}{1548149220000, "24.800000000000001"}, + []interface{}{1548149215000, "23.199999999999999"}, + []interface{}{1548149230000, "25.199999999999999"}, + []interface{}{1548149285000, "28"}, + []interface{}{1548149150000, "20"}, + }, + }, + []interface{}{ + "temperature:3:32", + [][]string{}, + []interface{}{ + []interface{}{1548149180000, "26.7"}, + []interface{}{1548149195000, "27.8"}, + []interface{}{1548149220000, "24.4"}, + []interface{}{1548149215000, "26.199999999999999"}, + []interface{}{1548149230000, "25.199999999999999"}, + []interface{}{1548149285000, "27"}, + []interface{}{1548149150000, "22"}, + }, + }, + }, + 0, + 0, + 2, + 7, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + "temperature:2:32", + "", + "", + nil, + }, + { + "should process receiver with aggregation and legend", + queryModel{Command: "ts.mrange", Key: "test1", Aggregation: "avg", Legend: "Legend", Bucket: 5000, Filter: "area_id=32 sensor_id!=1"}, + []interface{}{ + []interface{}{ + "temperature:2:32", + [][]string{}, + []interface{}{ + []interface{}{1548149180000, "26.199999999999999"}, + []interface{}{1548149185000, "27.399999999999999"}, + []interface{}{1548149190000, "24.800000000000001"}, + []interface{}{1548149195000, "23.199999999999999"}, + []interface{}{1548149200000, "25.199999999999999"}, + []interface{}{1548149205000, "28"}, + []interface{}{1548149210000, "20"}, + }, + }, + }, + 0, + 0, + 2, + 7, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + "", + "", + "", + nil, + }, + { + "should process receiver with labels specified in legend", + queryModel{Command: "ts.mrange", Key: "test1", Legend: "area_id", Filter: "area_id=32 sensor_id!=1"}, + []interface{}{ + []interface{}{ + "temperature:2:32", + [][]string{{"sensor_id", "2"}, {"area_id", "32"}}, + []interface{}{ + []interface{}{1548149210000, "20"}, + }, + }, + }, + 0, + 0, + 2, + 1, + nil, + "32", + "", + "", + nil, + }, + { + "should process receiver with value field existed in labels", + queryModel{Command: "ts.mrange", Key: "test1", Value: "sensor_id", Filter: "area_id=32 sensor_id!=1"}, + []interface{}{ + []interface{}{ + "temperature:2:32", + [][]string{{"sensor_id", "2"}, {"area_id", "32"}}, + []interface{}{ + []interface{}{1548149210000, "20"}, + }, + }, + }, + 0, + 0, + 2, + 1, + nil, + "temperature:2:32", + "2", + "", + nil, + }, + + { + "should process receiver with []byte field instead of int", + queryModel{Command: "ts.mrange", Key: "test1", Legend: "area_id", Filter: "area_id=32 sensor_id!=1"}, + []interface{}{ + []interface{}{ + "temperature:2:32", + [][]string{{"sensor_id", "2"}, {"area_id", "32"}}, + []interface{}{ + []interface{}{[]byte("1548149180000"), "26.199999999999999"}, + }, + }, + }, + 0, + 0, + 2, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + "32", + "", + "", + nil, + }, + + { + "should process receiver with Fill", + queryModel{Command: "ts.mrange", Key: "test1", Fill: true, Bucket: 5000, Filter: "area_id=32 sensor_id!=1"}, + []interface{}{ + []interface{}{ + "temperature:2:32", + [][]string{}, + []interface{}{ + []interface{}{1548149180000, "26.199999999999999"}, + []interface{}{1548149195000, "27.399999999999999"}, + []interface{}{1548149220000, "24.800000000000001"}, + []interface{}{1548149215000, "23.199999999999999"}, + []interface{}{1548149230000, "25.199999999999999"}, + []interface{}{1548149285000, "28"}, + []interface{}{1548149150000, "20"}, + }, + }, + }, + 0, + 0, + 2, + 25, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + "temperature:2:32", + "", + "", + nil, + }, + { + "should return error on bad filter", + queryModel{Command: "ts.mrange", Key: "test1", Filter: "\""}, + nil, + 0, + 0, + 0, + 0, + nil, + "", + "", + "Filter is not valid", + nil, + }, + { + "should process receiver error", + queryModel{Command: "ts.mrange", Key: "test1"}, + nil, + 0, + 0, + 0, + 0, + nil, + "", + "", + "error occurred", + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryTsMRange(tt.from, tt.to, tt.qm, client) + if tt.expectedError != "" { + require.EqualError(t, response.Error, tt.expectedError, "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.expectedFrameName, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + require.Equal(t, tt.expectedValueFieldName, response.Frames[0].Fields[1].Name, "Invalid field name") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQueryTsGet(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should process receiver without aggregation and legend provided", + queryModel{Command: "ts.get", Key: "test1"}, + []interface{}{1548149180000, "26.199999999999999"}, + 2, + 1, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: time.Unix(0, 1548149180000*int64(time.Millisecond))}, + {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: 26.2}, + }, + nil, + }, + { + "should process receiver error", + queryModel{Command: "ts.range", Key: "test1"}, + nil, + + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryTsGet(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} + +func TestQueryTsInfo(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valueToCheckByLabelInResponse []valueToCheckByLabelInResponse + err error + }{ + { + "should process receiver", + queryModel{Command: "ts.queryindex", Filter: "test1"}, + []interface{}{ + "totalSamples", int64(100), + "memoryUsage", int64(4184), + "firstTimestamp", int64(1548149180), + "lastTimestamp", int64(1548149279), + "retentionTime", int64(0), + "chunkCount", int64(1), + "chunkSize", int64(256), + "chunkType", "compressed", + "duplicatePolicy", nil, + "labels", [][]string{{"sensor_id", "2"}, {"area_id", "32"}}, + "sourceKey", nil, + "rules", []interface{}{}, + }, + 12, + 1, + []valueToCheckByLabelInResponse{ + {frameIndex: 0, fieldName: "totalSamples", rowIndex: 0, value: int64(100)}, + {frameIndex: 0, fieldName: "chunkType", rowIndex: 0, value: "compressed"}, + }, + nil, + }, + { + "should process receiver error", + queryModel{Command: "ts.info", Filter: "test1"}, + nil, + + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryTsInfo(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valueToCheckByLabelInResponse != nil { + for _, value := range tt.valueToCheckByLabelInResponse { + for _, field := range response.Frames[value.frameIndex].Fields { + if field.Name == value.fieldName { + require.Equalf(t, value.value, field.At(value.rowIndex), "Invalid value at Frame[%v]:Field[Name:%v]:Row[%v]", value.frameIndex, value.fieldName, value.rowIndex) + } + } + + } + } + } + }) + } +} + +func TestQueryTsQueryIndex(t *testing.T) { + t.Parallel() + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valuesToCheckInResponse []valueToCheckInResponse + err error + }{ + { + "should process receiver without aggregation and legend provided", + queryModel{Command: "ts.queryindex", Filter: "sensor_id=2"}, + []string{"temperature:2:32", "temperature:2:33"}, + 1, + 2, + []valueToCheckInResponse{ + {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "temperature:2:32"}, + {frameIndex: 0, fieldIndex: 0, rowIndex: 1, value: "temperature:2:33"}, + }, + nil, + }, + { + "should process error on bad filter", + queryModel{Command: "ts.queryindex", Filter: "\""}, + nil, + + 0, + 0, + nil, + errors.New("Filter is not valid"), + }, + { + "should process receiver error", + queryModel{Command: "ts.range", Filter: "test1"}, + nil, + + 0, + 0, + nil, + errors.New("error occurred"), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := redisDatasource{} + client := testClient{tt.rcv, tt.err} + response := ds.queryTsQueryIndex(tt.qm, client) + if tt.err != nil { + require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") + require.Nil(t, response.Frames, "No frames should be created if failed") + } else { + require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") + require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") + require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") + + if tt.valuesToCheckInResponse != nil { + for _, value := range tt.valuesToCheckInResponse { + require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) + } + } + } + }) + } +} diff --git a/pkg/testing-utilities_test.go b/pkg/testing-utilities_test.go new file mode 100644 index 0000000..910ee05 --- /dev/null +++ b/pkg/testing-utilities_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "github.com/mediocregopher/radix/v3" +) + +type testClient struct { + rcv interface{} + err error +} + +func (client testClient) Do(action radix.Action) error { + stub := radix.Stub("tcp", "127.0.0.1:6379", func(args []string) interface{} { + return client.rcv + }) + var _ = stub.Do(action) + return client.err +} +func (client testClient) Close() error { + return client.err +} + +type panickingClient struct { +} + +func (client panickingClient) Do(action radix.Action) error { + panic("Panic") +} +func (client panickingClient) Close() error { + return nil +} + +type valueToCheckInResponse struct { + frameIndex int + fieldIndex int + rowIndex int + value interface{} +} + +type valueToCheckByLabelInResponse struct { + frameIndex int + fieldName string + rowIndex int + value interface{} +} diff --git a/provisioning/dashboards/data-types.json b/provisioning/dashboards/data-types.json index 35e275a..ce570b0 100644 --- a/provisioning/dashboards/data-types.json +++ b/provisioning/dashboards/data-types.json @@ -15,7 +15,6 @@ "editable": true, "gnetId": null, "graphTooltip": 0, - "id": 1, "links": [], "panels": [ { @@ -1451,6 +1450,478 @@ "timeShift": null, "title": "XLEN test:stream", "type": "stat" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 39, + "panels": [], + "title": "RediSearch 2.0", + "type": "row" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "custom": { + "align": null, + "filterable": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 39 + }, + "id": 41, + "options": { + "showHeader": true + }, + "pluginVersion": "7.3.5", + "targets": [ + { + "command": "ft.info", + "keyName": "idx:test", + "query": "", + "refId": "A", + "type": "search" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Index idx:test", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "bytes_per_record_avg": true, + "doc_table_size_mb": false, + "indexing": true, + "inverted_sz_mb": false, + "max_doc_id": true, + "offset_bits_per_record_avg": true, + "offset_vectors_sz_mb": true, + "offsets_per_term_avg": true, + "records_per_doc_avg": true, + "sortable_values_size_mb": true, + "total_inverted_index_blocks": true + }, + "indexByName": { + "bytes_per_record_avg": 12, + "doc_table_size_mb": 14, + "hash_indexing_failures": 5, + "index_name": 0, + "indexing": 17, + "inverted_sz_mb": 11, + "key_table_size_mb": 9, + "max_doc_id": 6, + "num_docs": 2, + "num_records": 1, + "num_terms": 3, + "offset_bits_per_record_avg": 10, + "offset_vectors_sz_mb": 8, + "offsets_per_term_avg": 13, + "percent_indexed": 4, + "records_per_doc_avg": 16, + "sortable_values_size_mb": 15, + "total_inverted_index_blocks": 7 + }, + "renameByName": { + "bytes_per_record_avg": "", + "doc_table_size_mb": "Documents table size", + "hash_indexing_failures": "Failures", + "index_name": "Name", + "inverted_sz_mb": "Inverted size", + "key_table_size_mb": "Key Table size", + "num_docs": "Documents", + "num_records": "Records", + "num_terms": "Terms", + "percent_indexed": "Indexed" + } + } + } + ], + "type": "table" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "custom": { + "align": null, + "filterable": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 11, + "x": 0, + "y": 42 + }, + "hiddenSeries": false, + "id": 44, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.3.5", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "command": "ft.info", + "keyName": "idx:test", + "query": "", + "refId": "A", + "streaming": true, + "type": "search" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Index idx:test", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "bytes_per_record_avg": true, + "doc_table_size_mb": true, + "hash_indexing_failures": true, + "index_name": false, + "indexing": true, + "inverted_sz_mb": true, + "key_table_size_mb": true, + "max_doc_id": true, + "num_docs": false, + "num_records": false, + "num_terms": false, + "offset_bits_per_record_avg": true, + "offset_vectors_sz_mb": true, + "offsets_per_term_avg": true, + "percent_indexed": true, + "records_per_doc_avg": true, + "sortable_values_size_mb": true, + "total_inverted_index_blocks": true + }, + "indexByName": { + "bytes_per_record_avg": 13, + "doc_table_size_mb": 15, + "hash_indexing_failures": 6, + "index_name": 1, + "indexing": 18, + "inverted_sz_mb": 12, + "key_table_size_mb": 10, + "max_doc_id": 7, + "num_docs": 3, + "num_records": 2, + "num_terms": 4, + "offset_bits_per_record_avg": 11, + "offset_vectors_sz_mb": 9, + "offsets_per_term_avg": 14, + "percent_indexed": 5, + "records_per_doc_avg": 17, + "sortable_values_size_mb": 16, + "time": 0, + "total_inverted_index_blocks": 8 + }, + "renameByName": { + "bytes_per_record_avg": "", + "doc_table_size_mb": "Documents table size", + "hash_indexing_failures": "Failures", + "index_name": "Name", + "inverted_sz_mb": "Inverted size", + "key_table_size_mb": "Key Table size", + "num_docs": "Documents", + "num_records": "Records", + "num_terms": "Terms", + "percent_indexed": "Indexed" + } + } + } + ], + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:282", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:283", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "custom": { + "align": null, + "filterable": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 13, + "x": 11, + "y": 42 + }, + "hiddenSeries": false, + "id": 42, + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.3.5", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "command": "ft.info", + "keyName": "idx:test", + "query": "", + "refId": "A", + "streaming": true, + "type": "search" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Index idx:test Size", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "bytes_per_record_avg": true, + "doc_table_size_mb": false, + "hash_indexing_failures": true, + "index_name": true, + "indexing": true, + "inverted_sz_mb": false, + "max_doc_id": true, + "num_docs": true, + "num_records": true, + "num_terms": true, + "offset_bits_per_record_avg": true, + "offset_vectors_sz_mb": true, + "offsets_per_term_avg": true, + "percent_indexed": true, + "records_per_doc_avg": true, + "sortable_values_size_mb": true, + "total_inverted_index_blocks": true + }, + "indexByName": { + "bytes_per_record_avg": 13, + "doc_table_size_mb": 15, + "hash_indexing_failures": 6, + "index_name": 1, + "indexing": 18, + "inverted_sz_mb": 12, + "key_table_size_mb": 10, + "max_doc_id": 7, + "num_docs": 3, + "num_records": 2, + "num_terms": 4, + "offset_bits_per_record_avg": 11, + "offset_vectors_sz_mb": 9, + "offsets_per_term_avg": 14, + "percent_indexed": 5, + "records_per_doc_avg": 17, + "sortable_values_size_mb": 16, + "time": 0, + "total_inverted_index_blocks": 8 + }, + "renameByName": { + "bytes_per_record_avg": "", + "doc_table_size_mb": "Documents table size", + "hash_indexing_failures": "Failures", + "index_name": "Name", + "inverted_sz_mb": "Inverted size", + "key_table_size_mb": "Key Table size", + "num_docs": "Documents", + "num_records": "Records", + "num_terms": "Terms", + "percent_indexed": "Indexed" + } + } + } + ], + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:183", + "decimals": null, + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:184", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } } ], "schemaVersion": 26,