From 5638acdb72b8bdd79d048cca4ccd8dd1fa4e5641 Mon Sep 17 00:00:00 2001 From: slorello89 Date: Thu, 1 Jun 2023 11:04:23 -0400 Subject: [PATCH 01/10] FT.SEARCH --- .gitignore | 2 + pkg/models/redis-search.go | 1 + pkg/query.go | 3 + pkg/redis-search.go | 54 +++++++ pkg/redis-search_test.go | 107 ++++++++++++++ pkg/testing-utilities_test.go | 23 ++- pkg/types.go | 63 ++++---- .../QueryEditor/QueryEditor.test.tsx | 21 +++ src/components/QueryEditor/QueryEditor.tsx | 138 +++++++++++++++++- src/redis/command.ts | 6 + src/redis/fieldValuesContainer.ts | 3 + src/redis/search.ts | 34 +++++ src/redis/types.ts | 30 ++++ 13 files changed, 450 insertions(+), 35 deletions(-) create mode 100644 src/redis/fieldValuesContainer.ts diff --git a/.gitignore b/.gitignore index bdc5b68..40ab7c0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ vendor/ # Editor .idea .DS_Store + +data/dump.rdb \ No newline at end of file diff --git a/pkg/models/redis-search.go b/pkg/models/redis-search.go index 86bcf83..877c449 100644 --- a/pkg/models/redis-search.go +++ b/pkg/models/redis-search.go @@ -5,6 +5,7 @@ package models */ const ( SearchInfo = "ft.info" + Search = "ft.search" ) /** diff --git a/pkg/query.go b/pkg/query.go index eca2085..ab07b2c 100644 --- a/pkg/query.go +++ b/pkg/query.go @@ -102,6 +102,9 @@ func query(ctx context.Context, query backend.DataQuery, client redisClient, qm case models.SearchInfo: return queryFtInfo(qm, client) + case models.Search: + return queryFtSearch(qm, client) + /** * Custom commands */ diff --git a/pkg/redis-search.go b/pkg/redis-search.go index cbd867e..33f126a 100644 --- a/pkg/redis-search.go +++ b/pkg/redis-search.go @@ -9,6 +9,60 @@ import ( "github.com/redisgrafana/grafana-redis-datasource/pkg/models" ) +func queryFtSearch(qm queryModel, client redisClient) backend.DataResponse { + response := backend.DataResponse{} + + var result interface{} + args := []string{qm.Key} + if qm.SearchQuery == "" { + args = append(args, "*") + } else { + args = append(args, qm.SearchQuery) + } + + if qm.ReturnFields != nil && len(qm.ReturnFields) > 0 { + args = append(args, "RETURN") + args = append(args, strconv.Itoa(len(qm.ReturnFields))) + args = append(args, qm.ReturnFields...) + } + + if qm.Count != 0 || qm.Offset > 0 { + var count int + if qm.Count == 0 { + count = 10 + } else { + count = qm.Count + } + args = append(args, "LIMIT", strconv.Itoa(qm.Offset), strconv.Itoa(count)) + } + + if qm.SortBy != "" { + args = append(args, "SORTBY", qm.SortBy, qm.SortDirection) + } + + err := client.RunCmd(&result, qm.Command, args...) + + if err != nil { + return errorHandler(response, err) + } + + for i := 1; i < len(result.([]interface{})); i += 2 { + keyName := string((result.([]interface{}))[i].([]uint8)) + frame := data.NewFrame(keyName) + fieldValueArr := (result.([]interface{}))[i+1].([]interface{}) + frame.Fields = append(frame.Fields, data.NewField("keyName", nil, []string{keyName})) + for j := 0; j < len(fieldValueArr); j += 2 { + fieldName := string(fieldValueArr[j].([]uint8)) + fieldValue := string(fieldValueArr[j+1].([]uint8)) + frame.Fields = append(frame.Fields, data.NewField(fieldName, nil, []string{fieldValue})) + } + + response.Frames = append(response.Frames, frame) + } + + return response +} + /** * FT.INFO {index} * diff --git a/pkg/redis-search_test.go b/pkg/redis-search_test.go index 24af8e7..2dc0889 100644 --- a/pkg/redis-search_test.go +++ b/pkg/redis-search_test.go @@ -2,12 +2,119 @@ package main import ( "errors" + "fmt" "testing" "github.com/redisgrafana/grafana-redis-datasource/pkg/models" "github.com/stretchr/testify/require" ) +func TestQueryFtSearch(t *testing.T) { + t.Parallel() + + commonHashRcv := []interface{}{ + make([]uint8, 1), + []uint8("test:1"), + []interface{}{ + []uint8("name"), + []uint8("steve"), + []uint8("age"), + []uint8("34"), + }, + } + + commonHashCheck := []valueToCheckByLabelInResponse{ + {frameIndex: 0, fieldName: "key_name", rowIndex: 0, value: "test:1"}, + {frameIndex: 0, fieldName: "name", rowIndex: 0, value: "steve"}, + {frameIndex: 0, fieldName: "age", rowIndex: 0, value: "34"}, + } + + tests := []struct { + name string + qm queryModel + rcv interface{} + fieldsCount int + rowsPerField int + valueToCheckByLabelInResponse []valueToCheckByLabelInResponse + expectedArgs []string + expectedCmd string + err error + }{ + { + name: "simple search", + qm: queryModel{Command: models.Search, Key: "test", SearchQuery: "*"}, + rcv: commonHashRcv, + fieldsCount: 3, + rowsPerField: 1, + valueToCheckByLabelInResponse: commonHashCheck, + expectedArgs: []string{"test", "*"}, + expectedCmd: "ft.search", + }, { + name: "search with offset", + qm: queryModel{Command: models.Search, Key: "test", SearchQuery: "*", Offset: 50}, + rcv: commonHashRcv, + fieldsCount: 3, + rowsPerField: 1, + valueToCheckByLabelInResponse: commonHashCheck, + expectedArgs: []string{"test", "*", "LIMIT", "50", "10"}, + expectedCmd: "ft.search", + }, + { + name: "search with count", + qm: queryModel{Command: models.Search, Key: "test", SearchQuery: "*", Count: 15}, + rcv: commonHashRcv, + fieldsCount: 3, + rowsPerField: 1, + valueToCheckByLabelInResponse: commonHashCheck, + expectedArgs: []string{"test", "*", "LIMIT", "0", "15"}, + expectedCmd: "ft.search", + }, + { + name: "search with returns", + qm: queryModel{Command: models.Search, Key: "test", SearchQuery: "*", ReturnFields: []string{"foo", "bar", "baz"}}, + rcv: commonHashRcv, + fieldsCount: 3, + rowsPerField: 1, + valueToCheckByLabelInResponse: commonHashCheck, + expectedArgs: []string{"test", "*", "RETURN", "3", "foo", "bar", "baz"}, + expectedCmd: "ft.search", + }, + { + name: "search with SortBy", + qm: queryModel{Command: models.Search, Key: "test", SearchQuery: "*", SortDirection: "DESC", SortBy: "foo"}, + rcv: commonHashRcv, + fieldsCount: 3, + rowsPerField: 1, + valueToCheckByLabelInResponse: commonHashCheck, + expectedArgs: []string{"test", "*", "SORTBY", "foo", "DESC"}, + expectedCmd: "ft.search", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := testClient{rcv: tt.rcv, err: tt.err, expectedArgs: tt.expectedArgs, expectedCmd: tt.expectedCmd} + + response := queryFtSearch(tt.qm, &client) + + require.Nil(t, response.Error, fmt.Sprintf("Error:\n%s", response.Error)) + + 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) + } + } + } + } + }) + } +} + /** * FT.INFO */ diff --git a/pkg/testing-utilities_test.go b/pkg/testing-utilities_test.go index bdddc5c..9f23ea0 100644 --- a/pkg/testing-utilities_test.go +++ b/pkg/testing-utilities_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "reflect" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -13,11 +14,13 @@ import ( * Test client */ type testClient struct { - rcv interface{} - batchRcv [][]interface{} - batchErr []error - err error - batchCalls int + rcv interface{} + batchRcv [][]interface{} + batchErr []error + expectedArgs []string + expectedCmd string + err error + batchCalls int mock.Mock } @@ -74,6 +77,16 @@ func (client *testClient) RunCmd(rcv interface{}, cmd string, args ...string) er return client.err } + if client.expectedArgs != nil { + if !reflect.DeepEqual(args, client.expectedArgs) { + return fmt.Errorf("expected args did not match actuall args\nExpected:%s\nActual:%s\n", client.expectedArgs, args) + } + } + + if client.expectedCmd != "" && client.expectedCmd != cmd { + return fmt.Errorf("incorrect command, Expected:%s - Actual: %s", client.expectedCmd, cmd) + } + assignReceiver(rcv, client.rcv) return nil } diff --git a/pkg/types.go b/pkg/types.go index f42ec70..cd08973 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -40,33 +40,38 @@ type dataModel struct { * Query Model */ type queryModel struct { - Type string `json:"type"` - Query string `json:"query"` - Key string `json:"keyName"` - Field string `json:"field"` - Filter string `json:"filter"` - Command string `json:"command"` - Aggregation string `json:"aggregation"` - Bucket int `json:"bucket"` - Legend string `json:"legend"` - Value string `json:"value"` - Section string `json:"section"` - Size int `json:"size"` - Fill bool `json:"fill"` - Streaming bool `json:"streaming"` - StreamingDataType string `json:"streamingDataType"` - CLI bool `json:"cli"` - Cursor string `json:"cursor"` - Match string `json:"match"` - Count int `json:"count"` - Samples int `json:"samples"` - Unblocking bool `json:"unblocking"` - Requirements string `json:"requirements"` - Start string `json:"start"` - End string `json:"end"` - Cypher string `json:"cypher"` - Min string `json:"min"` - Max string `json:"max"` - ZRangeQuery string `json:"zrangeQuery"` - Path string `json:"path"` + Type string `json:"type"` + Query string `json:"query"` + Key string `json:"keyName"` + Field string `json:"field"` + Filter string `json:"filter"` + Command string `json:"command"` + Aggregation string `json:"aggregation"` + Bucket int `json:"bucket"` + Legend string `json:"legend"` + Value string `json:"value"` + Section string `json:"section"` + Size int `json:"size"` + Fill bool `json:"fill"` + Streaming bool `json:"streaming"` + StreamingDataType string `json:"streamingDataType"` + CLI bool `json:"cli"` + Cursor string `json:"cursor"` + Match string `json:"match"` + Count int `json:"count"` + Samples int `json:"samples"` + Unblocking bool `json:"unblocking"` + Requirements string `json:"requirements"` + Start string `json:"start"` + End string `json:"end"` + Cypher string `json:"cypher"` + Min string `json:"min"` + Max string `json:"max"` + ZRangeQuery string `json:"zrangeQuery"` + Path string `json:"path"` + SearchQuery string `json:"searchQuery"` + SortBy string `json:"sortBy"` + SortDirection string `json:"sortDirection"` + Offset int `json:"offset"` + ReturnFields []string `json:"returnFields"` } diff --git a/src/components/QueryEditor/QueryEditor.test.tsx b/src/components/QueryEditor/QueryEditor.test.tsx index f892a61..d19cb93 100644 --- a/src/components/QueryEditor/QueryEditor.test.tsx +++ b/src/components/QueryEditor/QueryEditor.test.tsx @@ -14,6 +14,7 @@ import { } from '../../redis'; import { getQuery } from '../../tests/utils'; import { QueryEditor } from './QueryEditor'; +import { RediSearch } from '../../redis/search'; type ShallowComponent = ShallowWrapper; @@ -324,6 +325,26 @@ describe('QueryEditor', () => { queryWhenShown: { refId: '', type: QueryTypeValue.GRAPH, command: RedisGraph.QUERY }, queryWhenHidden: { refId: '', type: QueryTypeValue.REDIS, command: Redis.INFO }, }, + { + name: 'offset', + getComponent: (wrapper: ShallowComponent) => + wrapper.findWhere((node) => { + return node.prop('onChange') === wrapper.instance().onOffsetChange; + }), + type: 'number', + queryWhenShown: { refId: '', type: QueryTypeValue.SEARCH, command: RediSearch.SEARCH }, + queryWhenHidden: { refId: '', type: QueryTypeValue.REDIS, command: Redis.INFO }, + }, + { + name: 'searchQuery', + getComponent: (wrapper: ShallowComponent) => + wrapper.findWhere((node) => { + return node.prop('onChange') === wrapper.instance().onSearchQueryChange; + }), + type: 'string', + queryWhenShown: { refId: '', type: QueryTypeValue.SEARCH, command: RediSearch.SEARCH }, + queryWhenHidden: { refId: '', type: QueryTypeValue.REDIS, command: Redis.INFO }, + }, { name: 'path', getComponent: (wrapper: ShallowComponent) => diff --git a/src/components/QueryEditor/QueryEditor.tsx b/src/components/QueryEditor/QueryEditor.tsx index b32544d..1aa929b 100644 --- a/src/components/QueryEditor/QueryEditor.tsx +++ b/src/components/QueryEditor/QueryEditor.tsx @@ -3,7 +3,17 @@ import { RedisGraph } from 'redis/graph'; import { css } from '@emotion/css'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; -import { Button, InlineFormLabel, LegacyForms, RadioButtonGroup, Select, TextArea } from '@grafana/ui'; +import { + Button, + FieldArray, + Form, + InlineFormLabel, + Input, + LegacyForms, + RadioButtonGroup, + Select, + TextArea, +} from '@grafana/ui'; import { StreamingDataType, StreamingDataTypes } from '../../constants'; import { DataSource } from '../../datasource'; import { @@ -25,6 +35,8 @@ import { ZRangeQueryValue, } from '../../redis'; import { RedisDataSourceOptions } from '../../types'; +import { RediSearch, SortDirection, SortDirectionValue } from '../../redis/search'; +import { FieldValuesContainer } from '../../redis/fieldValuesContainer'; /** * Form Field @@ -98,6 +110,15 @@ export class QueryEditor extends PureComponent { this.props.onChange({ ...this.props.query, [name]: event.currentTarget.checked }); }; + createFieldArrayHandler = (name: 'returnFields') => (event: React.SyntheticEvent) => { + const index = Number(event.currentTarget.name.split(':', 2)[1]); + if (!this.props.query[name]) { + this.props.query[name] = []; + } + + this.props.query[name]![index] = event.currentTarget.value; + }; + /** * Key name change */ @@ -108,6 +129,11 @@ export class QueryEditor extends PureComponent { */ onQueryChange = this.createTextareaFieldHandler('query'); + /** + * searchQuery change + */ + onSearchQueryChange = this.createTextareaFieldHandler('searchQuery'); + /** * Filter change */ @@ -138,6 +164,11 @@ export class QueryEditor extends PureComponent { */ onValueChange = this.createTextFieldHandler('value'); + /** + * sortBy change + */ + onSortByChange = this.createTextFieldHandler('sortBy'); + /** * Command change */ @@ -168,6 +199,11 @@ export class QueryEditor extends PureComponent { */ onZRangeQueryChange = this.createSelectFieldHandler('zrangeQuery'); + /** + * FT.SEARCH Sort By + */ + onSortDirectionChange = this.createSelectFieldHandler('sortDirection'); + /** * Info section change */ @@ -188,6 +224,11 @@ export class QueryEditor extends PureComponent { */ onCountChange = this.createNumberFieldHandler('count'); + /** + * Count change + */ + onOffsetChange = this.createNumberFieldHandler('offset'); + /** * Samples change */ @@ -248,10 +289,16 @@ export class QueryEditor extends PureComponent { */ onStreamingDataTypeChange = this.createRedioButtonFieldHandler('streamingDataType'); + onReturnFieldChange = this.createFieldArrayHandler('returnFields'); + /** * Render Editor */ render() { + const defaultValues: FieldValuesContainer = { + fieldArray: [''], + }; + const { keyName, aggregation, @@ -265,6 +312,10 @@ export class QueryEditor extends PureComponent { path, value, query, + searchQuery, + offset, + sortDirection, + sortBy, type, section, size, @@ -459,6 +510,91 @@ export class QueryEditor extends PureComponent { )} + {command && CommandParameters.searchQuery.includes(command as RediSearch) && ( +
+ + Query + +