diff --git a/pkg/ffapi/filter.go b/pkg/ffapi/filter.go index 69e6708..f8ac8c9 100644 --- a/pkg/ffapi/filter.go +++ b/pkg/ffapi/filter.go @@ -426,6 +426,8 @@ func (f *baseFilter) Finalize() (fi *FilterInfo, err error) { var values []FieldSerialization var mods []FieldMod + resolvedField := f.field + switch f.op { case FilterOpAnd, FilterOpOr: children = make([]*FilterInfo, len(f.children)) @@ -437,11 +439,11 @@ func (f *baseFilter) Finalize() (fi *FilterInfo, err error) { case FilterOpIn, FilterOpNotIn: fValues := f.value.([]driver.Value) values = make([]FieldSerialization, len(fValues)) - name := strings.ToLower(f.field) - field, ok := f.fb.queryFields[name] + name, field, ok := f.fb.queryFields.Resolve(f.field) if !ok { - return nil, i18n.NewError(f.fb.ctx, i18n.MsgInvalidFilterField, name) + return nil, i18n.NewError(f.fb.ctx, i18n.MsgInvalidFilterField, f.field) } + resolvedField = name mods = fieldMods(field) for i, fv := range fValues { values[i] = field.GetSerialization() @@ -450,11 +452,11 @@ func (f *baseFilter) Finalize() (fi *FilterInfo, err error) { } } default: - name := strings.ToLower(f.field) - field, ok := f.fb.queryFields[name] + name, field, ok := f.fb.queryFields.Resolve(f.field) if !ok { - return nil, i18n.NewError(f.fb.ctx, i18n.MsgInvalidFilterField, name) + return nil, i18n.NewError(f.fb.ctx, i18n.MsgInvalidFilterField, f.field) } + resolvedField = name mods = fieldMods(field) skipScan := false switch f.value.(type) { @@ -505,7 +507,7 @@ func (f *baseFilter) Finalize() (fi *FilterInfo, err error) { return &FilterInfo{ Children: children, Op: f.op, - Field: f.field, + Field: resolvedField, FieldMods: mods, Values: values, Value: value, @@ -525,9 +527,9 @@ func (fb *filterBuilder) Sort(fields ...string) FilterBuilder { field = strings.TrimPrefix(field, "-") descending = true } - if _, ok := fb.queryFields[field]; ok { + if name, _, ok := fb.queryFields.Resolve(field); ok { fb.sort = append(fb.sort, &SortField{ - Field: field, + Field: name, Descending: descending, }) } @@ -542,8 +544,8 @@ func (f *baseFilter) Sort(fields ...string) Filter { func (fb *filterBuilder) GroupBy(fields ...string) FilterBuilder { for _, field := range fields { - if _, ok := fb.queryFields[field]; ok { - fb.groupBy = append(fb.groupBy, field) + if name, _, ok := fb.queryFields.Resolve(field); ok { + fb.groupBy = append(fb.groupBy, name) } } return fb @@ -556,8 +558,8 @@ func (f *baseFilter) GroupBy(fields ...string) Filter { func (fb *filterBuilder) RequiredFields(fields ...string) FilterBuilder { for _, field := range fields { - if _, ok := fb.queryFields[field]; ok { - fb.requiredFields = append(fb.requiredFields, field) + if name, _, ok := fb.queryFields.Resolve(field); ok { + fb.requiredFields = append(fb.requiredFields, name) } } return fb diff --git a/pkg/ffapi/filter_test.go b/pkg/ffapi/filter_test.go index 4607350..15c15af 100644 --- a/pkg/ffapi/filter_test.go +++ b/pkg/ffapi/filter_test.go @@ -373,3 +373,63 @@ func TestValueFilterAccess(t *testing.T) { assert.Equal(t, "seq", fb.ValueFilter().Field()) assert.Equal(t, 12345, fb.ValueFilter().Value()) } + +// caseTestFactory declares camelCase fields, which resolve only if lookups fold case. +var caseTestFactory = &QueryFields{ + "id": &Int64Field{}, + "parentAccount": &StringField{}, + "effectiveTime": &TimeField{}, +} + +func TestFilterFieldAnyCase(t *testing.T) { + for _, spelling := range []string{"parentAccount", "parentaccount", "PARENTACCOUNT"} { + fb := caseTestFactory.NewFilter(context.Background()) + fi, err := fb.Eq(spelling, "urn:kld:account=abc").Finalize() + assert.NoError(t, err) + assert.Equal(t, "parentAccount", fi.Field, "Eq %q", spelling) + + fb = caseTestFactory.NewFilter(context.Background()) + fi, err = fb.In(spelling, []driver.Value{"a", "b"}).Finalize() + assert.NoError(t, err) + assert.Equal(t, "parentAccount", fi.Field, "In %q", spelling) + } +} + +func TestFilterFieldAnyCaseUnknown(t *testing.T) { + fb := caseTestFactory.NewFilter(context.Background()) + _, err := fb.Eq("NoSuchField", "value").Finalize() + assert.Regexp(t, "FF00142.*NoSuchField", err) + + fb = caseTestFactory.NewFilter(context.Background()) + _, err = fb.In("NoSuchField", []driver.Value{"a"}).Finalize() + assert.Regexp(t, "FF00142.*NoSuchField", err) +} + +func TestSortGroupByAnyCase(t *testing.T) { + fb := caseTestFactory.NewFilter(context.Background()) + fi, err := fb.Eq("id", 1). + Sort("-effectivetime", "PARENTACCOUNT"). + GroupBy("parentaccount"). + RequiredFields("EFFECTIVETIME"). + Finalize() + assert.NoError(t, err) + assert.Equal(t, "effectiveTime", fi.Sort[0].Field) + assert.True(t, fi.Sort[0].Descending) + assert.Equal(t, "parentAccount", fi.Sort[1].Field) + assert.False(t, fi.Sort[1].Descending) + assert.Equal(t, []string{"parentAccount"}, fi.GroupBy) + assert.Equal(t, []string{"effectiveTime"}, fi.RequiredFields) +} + +func TestSortGroupByUnknownDropped(t *testing.T) { + fb := caseTestFactory.NewFilter(context.Background()) + fi, err := fb.Eq("id", 1). + Sort("nosuchfield"). + GroupBy("nosuchfield"). + RequiredFields("nosuchfield"). + Finalize() + assert.NoError(t, err) + assert.Empty(t, fi.Sort) + assert.Empty(t, fi.GroupBy) + assert.Empty(t, fi.RequiredFields) +} diff --git a/pkg/ffapi/query_fields.go b/pkg/ffapi/query_fields.go index 27d4146..864df49 100644 --- a/pkg/ffapi/query_fields.go +++ b/pkg/ffapi/query_fields.go @@ -72,6 +72,20 @@ func (qf QueryFields) NewUpdate(ctx context.Context) UpdateBuilder { } } +// Resolve looks up a field by name, falling back to a case-insensitive match when there is no exact +// one. The returned name is the one declared in the map. +func (qf QueryFields) Resolve(fieldAnyCase string) (string, Field, bool) { + if f, ok := qf[fieldAnyCase]; ok { + return fieldAnyCase, f, true + } + for name, f := range qf { + if strings.EqualFold(name, fieldAnyCase) { + return name, f, true + } + } + return "", nil, false +} + func (qf QueryFields) Clone() QueryFields { qf2 := make(QueryFields, len(qf)) for n, f := range qf {