diff --git a/pkg/datastore/datastore.go b/pkg/datastore/datastore.go index 16ec32e218..54a3523b0e 100644 --- a/pkg/datastore/datastore.go +++ b/pkg/datastore/datastore.go @@ -51,6 +51,13 @@ const ( OperatorContains ) +func (o Operator) IsNumericOperator() bool { + return o == OperatorGreaterThan || + o == OperatorGreaterThanOrEqual || + o == OperatorLessThan || + o == OperatorLessThanOrEqual +} + var ( ErrNotFound = errors.New("not found") ErrInvalidArgument = errors.New("invalid argument") diff --git a/pkg/datastore/filedb/BUILD.bazel b/pkg/datastore/filedb/BUILD.bazel index 30d573309e..75705d480a 100644 --- a/pkg/datastore/filedb/BUILD.bazel +++ b/pkg/datastore/filedb/BUILD.bazel @@ -22,7 +22,15 @@ go_library( go_test( name = "go_default_test", size = "small", - srcs = ["codec_test.go"], + srcs = [ + "codec_test.go", + "filter_test.go", + ], embed = [":go_default_library"], - deps = ["@com_github_stretchr_testify//assert:go_default_library"], + deps = [ + "//pkg/datastore:go_default_library", + "//pkg/model:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], ) diff --git a/pkg/datastore/filedb/filter.go b/pkg/datastore/filedb/filter.go index cddfce190b..6dcc6cb551 100644 --- a/pkg/datastore/filedb/filter.go +++ b/pkg/datastore/filedb/filter.go @@ -15,19 +15,186 @@ package filedb import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "unicode" + "github.com/pipe-cd/pipecd/pkg/datastore" ) -// TODO: Implement filterable interface for each collection. type filterable interface { Match(e interface{}, filters []datastore.ListFilter) (bool, error) } func filter(col datastore.Collection, e interface{}, filters []datastore.ListFilter) (bool, error) { + // Always pass, if there is no filter. + if len(filters) == 0 { + return true, nil + } + + // If the collection implement filterable interface, use it. fcol, ok := col.(filterable) - if !ok { + if ok { + return fcol.Match(e, filters) + } + + // remarshal entity as map[string]interface{} struct. + raw, err := json.Marshal(e) + if err != nil { + return false, err + } + var omap map[string]interface{} + if err := json.Unmarshal(raw, &omap); err != nil { + return false, err + } + + for _, filter := range filters { + field := convertCamelToSnake(filter.Field) + if strings.Contains(field, ".") { + // TODO: Handle nested field name such as SyncState.Status. + return false, datastore.ErrUnsupported + } + + val, ok := omap[field] + // If the object does not contain given field name in filter, return false immidiately. + if !ok { + return false, nil + } + + cmp, err := compare(val, filter.Value, filter.Operator) + if err != nil { + return false, err + } + + if !cmp { + return false, nil + } + } + + return true, nil +} + +func compare(val, operand interface{}, op datastore.Operator) (bool, error) { + var valNum, operandNum int64 + switch v := val.(type) { + case int, int8, int16, int32, int64: + valNum = reflect.ValueOf(v).Int() + case uint, uint8, uint16, uint32: + valNum = int64(reflect.ValueOf(v).Uint()) + default: + if op.IsNumericOperator() { + return false, fmt.Errorf("value of type unsupported") + } + } + switch o := operand.(type) { + case int, int8, int16, int32, int64: + operandNum = reflect.ValueOf(o).Int() + case uint, uint8, uint16, uint32: + operandNum = int64(reflect.ValueOf(o).Uint()) + default: + if op.IsNumericOperator() { + return false, fmt.Errorf("operand of type unsupported") + } + } + + switch op { + case datastore.OperatorEqual: + return val == operand, nil + case datastore.OperatorNotEqual: + return val != operand, nil + case datastore.OperatorGreaterThan: + return valNum > operandNum, nil + case datastore.OperatorGreaterThanOrEqual: + return valNum >= operandNum, nil + case datastore.OperatorLessThan: + return valNum < operandNum, nil + case datastore.OperatorLessThanOrEqual: + return valNum <= operandNum, nil + case datastore.OperatorIn: + os, err := makeSliceOfInterfaces(operand) + if err != nil { + return false, fmt.Errorf("operand error: %w", err) + } + + for _, o := range os { + if o == val { + return true, nil + } + } + return false, nil + case datastore.OperatorNotIn: + os, err := makeSliceOfInterfaces(operand) + if err != nil { + return false, fmt.Errorf("operand error: %w", err) + } + + for _, o := range os { + if o == val { + return false, nil + } + } + return true, nil + case datastore.OperatorContains: + vs, err := makeSliceOfInterfaces(val) + if err != nil { + return false, fmt.Errorf("value error: %w", err) + } + + for _, v := range vs { + if v == operand { + return true, nil + } + } + return false, nil + default: return false, datastore.ErrUnsupported } +} - return fcol.Match(e, filters) +func makeSliceOfInterfaces(v interface{}) ([]interface{}, error) { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil, fmt.Errorf("value is not a slide or array") + } + + vs := make([]interface{}, rv.Len()) + for i := 0; i < rv.Len(); i++ { + vs[i] = rv.Index(i).Interface() + } + + return vs, nil +} + +func convertCamelToSnake(key string) string { + runeToLower := func(r rune) string { + return strings.ToLower(string(r)) + } + + var out string + for i, v := range key { + if i == 0 { + out += runeToLower(v) + continue + } + + if i == len(key)-1 { + out += runeToLower(v) + break + } + + if unicode.IsUpper(v) && unicode.IsLower(rune(key[i+1])) { + out += fmt.Sprintf("_%s", runeToLower(v)) + continue + } + + if unicode.IsUpper(v) { + out += runeToLower(v) + continue + } + + out += string(v) + } + return out } diff --git a/pkg/datastore/filedb/filter_test.go b/pkg/datastore/filedb/filter_test.go new file mode 100644 index 0000000000..141209c940 --- /dev/null +++ b/pkg/datastore/filedb/filter_test.go @@ -0,0 +1,257 @@ +// Copyright 2022 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filedb + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/datastore" + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestConvertCamelToSnake(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + camel string + snake string + }{ + { + name: "single camel", + camel: "Id", + snake: "id", + }, + { + name: "full of upper cases", + camel: "API", + snake: "api", + }, + { + name: "mix with full of upper cases word", + camel: "APIKey", + snake: "api_key", + }, + { + name: "formal camel", + camel: "StaticAdminDisabled", + snake: "static_admin_disabled", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + out := convertCamelToSnake(tc.camel) + assert.Equal(t, tc.snake, out) + }) + } +} + +func TestCompare(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + val interface{} + operand interface{} + operator datastore.Operator + expect bool + expectErr bool + }{ + { + name: "equal number int", + val: 5, + operand: 5, + operator: datastore.OperatorEqual, + expect: true, + }, + { + name: "equal string", + val: "text", + operand: "text", + operator: datastore.OperatorEqual, + expect: true, + }, + { + name: "not equal int", + val: 3, + operand: 2, + operator: datastore.OperatorNotEqual, + expect: true, + }, + { + name: "not equal string", + val: "text_val", + operand: "text_operand", + operator: datastore.OperatorNotEqual, + expect: true, + }, + { + name: "greater than int", + val: 3, + operand: 1, + operator: datastore.OperatorGreaterThan, + expect: true, + }, + { + name: "greater than or equal int", + val: 3, + operand: 3, + operator: datastore.OperatorGreaterThanOrEqual, + expect: true, + }, + { + name: "in int", + val: 1, + operand: []int{1, 2, 3}, + operator: datastore.OperatorIn, + expect: true, + }, + { + name: "in int false", + val: 4, + operand: []int{1, 2, 3}, + operator: datastore.OperatorIn, + expect: false, + }, + { + name: "not in int", + val: 4, + operand: []int{1, 2, 3}, + operator: datastore.OperatorNotIn, + expect: true, + }, + { + name: "not in int false", + val: 1, + operand: []int{1, 2, 3}, + operator: datastore.OperatorNotIn, + expect: false, + }, + { + name: "contains int", + val: []int{1, 2, 3}, + operand: 1, + operator: datastore.OperatorContains, + expect: true, + }, + { + name: "error on query for numeric only operator with wrong value", + val: "string_1", + operand: "string_0", + operator: datastore.OperatorGreaterThan, + expectErr: true, + }, + { + name: "error on query in operator with not operand of type slide/array", + val: 1, + operand: 1, + operator: datastore.OperatorIn, + expectErr: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + res, err := compare(tc.val, tc.operand, tc.operator) + require.Equal(t, tc.expectErr, err != nil) + + if err != nil { + assert.Equal(t, tc.expect, res) + } + }) + } +} + +func TestFilter(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + entity interface{} + filters []datastore.ListFilter + expect bool + }{ + { + name: "filter single condition - passed", + entity: &model.Project{Id: "project_1"}, + filters: []datastore.ListFilter{ + { + Field: "Id", + Operator: datastore.OperatorEqual, + Value: "project_1", + }, + }, + expect: true, + }, + { + name: "filter single condition - not passed", + entity: &model.Project{Id: "project_1"}, + filters: []datastore.ListFilter{ + { + Field: "Id", + Operator: datastore.OperatorEqual, + Value: "project_2", + }, + }, + expect: false, + }, + { + name: "filter multiple conditions - passed", + entity: &model.Project{Id: "project_1", StaticAdminDisabled: true}, + filters: []datastore.ListFilter{ + { + Field: "Id", + Operator: datastore.OperatorEqual, + Value: "project_1", + }, + { + Field: "StaticAdminDisabled", + Operator: datastore.OperatorEqual, + Value: true, + }, + }, + expect: true, + }, + { + name: "filter multiple conditions - not passed", + entity: &model.Project{Id: "project_1", StaticAdminDisabled: true}, + filters: []datastore.ListFilter{ + { + Field: "Id", + Operator: datastore.OperatorEqual, + Value: "project_1", + }, + { + Field: "StaticAdminDisabled", + Operator: datastore.OperatorEqual, + Value: false, + }, + }, + expect: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + passed, err := filter(nil, tc.entity, tc.filters) + require.Nil(t, err) + assert.Equal(t, tc.expect, passed) + }) + } +}