From 5d025b43da2d694217171685143d3a31ef0ef980 Mon Sep 17 00:00:00 2001 From: Andrew Richardson Date: Wed, 19 Aug 2026 12:41:44 -0400 Subject: [PATCH] Resolve filter field names case-insensitively Currently a QueryFields map only supports lowercase field names, because Finalize lowercases the field name before looking it up. Sort, GroupBy and RequiredFields instead match exactly, and silently drop anything else. With this change, mixed-case filter fields are now declarable. QueryFields.Resolve matches a field name exactly, then falls back to a case-insensitive match, and returns the name as declared. This applies to filter params as well as sort, group by, and fields. Signed-off-by: Andrew Richardson --- pkg/ffapi/filter.go | 28 +++++++++--------- pkg/ffapi/filter_test.go | 60 +++++++++++++++++++++++++++++++++++++++ pkg/ffapi/query_fields.go | 14 +++++++++ 3 files changed, 89 insertions(+), 13 deletions(-) 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 {