Skip to content

Commit 3e45fc4

Browse files
authored
Merge pull request #28 from linuxfoundation/andrest50/query-direct-fields
[LFXV2-924] Add direct field filtering support for data object queries
2 parents 0a8e1d7 + d715e40 commit 3e45fc4

File tree

17 files changed

+332
-20
lines changed

17 files changed

+332
-20
lines changed

cmd/service/converters.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package service
55

66
import (
77
"context"
8+
"fmt"
89
"log/slog"
10+
"strings"
911

1012
querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc"
1113
"github.com/linuxfoundation/lfx-v2-query-service/internal/domain/model"
@@ -14,15 +16,48 @@ import (
1416
"github.com/linuxfoundation/lfx-v2-query-service/pkg/paging"
1517
)
1618

19+
// parseFilters parses filter strings in "field:value" format
20+
// All fields are automatically prefixed with "data." to filter only within the data object
21+
func parseFilters(filters []string) ([]model.FieldFilter, error) {
22+
if len(filters) == 0 {
23+
return nil, nil
24+
}
25+
26+
parsed := make([]model.FieldFilter, 0, len(filters))
27+
for _, filter := range filters {
28+
parts := strings.SplitN(filter, ":", 2)
29+
if len(parts) != 2 {
30+
return nil, fmt.Errorf("invalid filter format '%s': expected 'field:value'", filter)
31+
}
32+
fieldName := strings.TrimSpace(parts[0])
33+
if fieldName == "" {
34+
return nil, fmt.Errorf("invalid filter format '%s': field name cannot be empty", filter)
35+
}
36+
// Automatically prefix with "data." to ensure filtering only on data fields
37+
parsed = append(parsed, model.FieldFilter{
38+
Field: "data." + fieldName,
39+
Value: strings.TrimSpace(parts[1]),
40+
})
41+
}
42+
return parsed, nil
43+
}
44+
1745
// payloadToCriteria converts the generated payload to domain search criteria
1846
func (s *querySvcsrvc) payloadToCriteria(ctx context.Context, p *querysvc.QueryResourcesPayload) (model.SearchCriteria, error) {
47+
// Parse filters from "field:value" format
48+
filters, err := parseFilters(p.Filters)
49+
if err != nil {
50+
slog.ErrorContext(ctx, "failed to parse filters", "error", err)
51+
return model.SearchCriteria{}, wrapError(ctx, err)
52+
}
1953

2054
criteria := model.SearchCriteria{
2155
Name: p.Name,
2256
Parent: p.Parent,
2357
ResourceType: p.Type,
2458
Tags: p.Tags,
2559
TagsAll: p.TagsAll,
60+
Filters: filters,
2661
CelFilter: p.CelFilter,
2762
SortBy: p.Sort,
2863
PageToken: p.PageToken,
@@ -81,7 +116,7 @@ func (s *querySvcsrvc) domainResultToResponse(result *model.SearchResult) *query
81116
return response
82117
}
83118

84-
func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria {
119+
func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResourcesCountPayload) (model.SearchCriteria, error) {
85120
// Parameters used for /<index>/_count search.
86121
criteria := model.SearchCriteria{
87122
GroupBySize: constants.DefaultBucketSize,
@@ -91,9 +126,16 @@ func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResou
91126
PublicOnly: true,
92127
}
93128

129+
// Parse filters from "field:value" format
130+
filters, err := parseFilters(payload.Filters)
131+
if err != nil {
132+
return criteria, fmt.Errorf("invalid filters: %w", err)
133+
}
134+
94135
// Set the criteria from the payload
95136
criteria.Tags = payload.Tags
96137
criteria.TagsAll = payload.TagsAll
138+
criteria.Filters = filters
97139
if payload.Name != nil {
98140
criteria.Name = payload.Name
99141
}
@@ -104,10 +146,10 @@ func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResou
104146
criteria.ParentRef = payload.Parent
105147
}
106148

107-
return criteria
149+
return criteria, nil
108150
}
109151

110-
func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria {
152+
func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.QueryResourcesCountPayload) (model.SearchCriteria, error) {
111153
// Parameters used for the "group by" aggregated /<index>/_search search.
112154
criteria := model.SearchCriteria{
113155
GroupBySize: constants.DefaultBucketSize,
@@ -120,9 +162,16 @@ func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.Query
120162
GroupBy: "access_check_query.keyword",
121163
}
122164

165+
// Parse filters from "field:value" format
166+
filters, err := parseFilters(payload.Filters)
167+
if err != nil {
168+
return criteria, fmt.Errorf("invalid filters: %w", err)
169+
}
170+
123171
// Set the criteria from the payload
124172
criteria.Tags = payload.Tags
125173
criteria.TagsAll = payload.TagsAll
174+
criteria.Filters = filters
126175
if payload.Name != nil {
127176
criteria.Name = payload.Name
128177
}
@@ -133,7 +182,7 @@ func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.Query
133182
criteria.ParentRef = payload.Parent
134183
}
135184

136-
return criteria
185+
return criteria, nil
137186
}
138187

139188
func (s *querySvcsrvc) domainCountResultToResponse(result *model.CountResult) *querysvc.QueryResourcesCountResult {

cmd/service/converters_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,105 @@ func TestDomainOrganizationSuggestionsToResponse(t *testing.T) {
590590
}
591591
}
592592

593+
func TestParseFilters(t *testing.T) {
594+
tests := []struct {
595+
name string
596+
filters []string
597+
expected []model.FieldFilter
598+
expectedError bool
599+
errorSubstring string
600+
}{
601+
{
602+
name: "valid single filter - auto-prefixed with data",
603+
filters: []string{"status:active"},
604+
expected: []model.FieldFilter{{Field: "data.status", Value: "active"}},
605+
expectedError: false,
606+
},
607+
{
608+
name: "valid multiple filters - auto-prefixed with data",
609+
filters: []string{"status:active", "priority:high"},
610+
expected: []model.FieldFilter{
611+
{Field: "data.status", Value: "active"},
612+
{Field: "data.priority", Value: "high"},
613+
},
614+
expectedError: false,
615+
},
616+
{
617+
name: "filter with spaces (trimmed and auto-prefixed)",
618+
filters: []string{" status : active "},
619+
expected: []model.FieldFilter{{Field: "data.status", Value: "active"}},
620+
expectedError: false,
621+
},
622+
{
623+
name: "filter with colon in value",
624+
filters: []string{"url:https://example.com"},
625+
expected: []model.FieldFilter{{Field: "data.url", Value: "https://example.com"}},
626+
expectedError: false,
627+
},
628+
{
629+
name: "invalid filter format (no colon)",
630+
filters: []string{"invalid"},
631+
expected: nil,
632+
expectedError: true,
633+
errorSubstring: "invalid filter format",
634+
},
635+
{
636+
name: "invalid filter format (empty after colon)",
637+
filters: []string{"status:"},
638+
expected: []model.FieldFilter{{Field: "data.status", Value: ""}},
639+
expectedError: false,
640+
},
641+
{
642+
name: "invalid filter format (empty field name)",
643+
filters: []string{":value"},
644+
expected: nil,
645+
expectedError: true,
646+
errorSubstring: "field name cannot be empty",
647+
},
648+
{
649+
name: "invalid filter format (whitespace-only field name)",
650+
filters: []string{" :value"},
651+
expected: nil,
652+
expectedError: true,
653+
errorSubstring: "field name cannot be empty",
654+
},
655+
{
656+
name: "empty filters array",
657+
filters: []string{},
658+
expected: nil,
659+
expectedError: false,
660+
},
661+
{
662+
name: "nil filters",
663+
filters: nil,
664+
expected: nil,
665+
expectedError: false,
666+
},
667+
{
668+
name: "nested field name (auto-prefixed)",
669+
filters: []string{"project.id:123"},
670+
expected: []model.FieldFilter{{Field: "data.project.id", Value: "123"}},
671+
expectedError: false,
672+
},
673+
}
674+
675+
for _, tc := range tests {
676+
t.Run(tc.name, func(t *testing.T) {
677+
result, err := parseFilters(tc.filters)
678+
679+
if tc.expectedError {
680+
assert.Error(t, err)
681+
if tc.errorSubstring != "" {
682+
assert.Contains(t, err.Error(), tc.errorSubstring)
683+
}
684+
} else {
685+
assert.NoError(t, err)
686+
assert.Equal(t, tc.expected, result)
687+
}
688+
})
689+
}
690+
}
691+
593692
// Helper function to create string pointers
594693
func stringPtr(s string) *string {
595694
return &s

cmd/service/service.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,14 @@ func (s *querySvcsrvc) QueryResourcesCount(ctx context.Context, p *querysvc.Quer
7676
)
7777

7878
// Convert payload to domain criteria
79-
countCriteria := s.payloadToCountPublicCriteria(p)
80-
aggregationCriteria := s.payloadToCountAggregationCriteria(p)
79+
countCriteria, errCountCriteria := s.payloadToCountPublicCriteria(p)
80+
if errCountCriteria != nil {
81+
return nil, wrapError(ctx, errCountCriteria)
82+
}
83+
aggregationCriteria, errAggCriteria := s.payloadToCountAggregationCriteria(p)
84+
if errAggCriteria != nil {
85+
return nil, wrapError(ctx, errAggCriteria)
86+
}
8187

8288
// Execute search using the service layer
8389
result, errQueryResources := s.resourceService.QueryResourcesCount(ctx, countCriteria, aggregationCriteria)

design/query-svc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ var _ = dsl.Service("query-svc", func() {
5656
dsl.Attribute("tags_all", dsl.ArrayOf(dsl.String), "Tags to search with AND logic - matches resources that have all of these tags", func() {
5757
dsl.Example([]string{"governance", "security"})
5858
})
59+
dsl.Attribute("filters", dsl.ArrayOf(dsl.String), "Direct field filters with term clauses on data fields - format: 'field:value' (e.g., 'status:active'). Fields are automatically prefixed with 'data.'", func() {
60+
dsl.Example([]string{"status:active", "priority:high"})
61+
})
5962
dsl.Attribute("cel_filter", dsl.String, "CEL expression to filter results on resource data fields. Available variables: data (map), resource_type (string), id (string)", func() {
6063
dsl.Example(`data.slug == "tlf"`)
6164
dsl.MaxLength(1000)
@@ -82,6 +85,7 @@ var _ = dsl.Service("query-svc", func() {
8285
dsl.Param("type")
8386
dsl.Param("tags")
8487
dsl.Param("tags_all")
88+
dsl.Param("filters")
8589
dsl.Param("cel_filter")
8690
dsl.Param("sort")
8791
dsl.Param("page_token")
@@ -125,6 +129,9 @@ var _ = dsl.Service("query-svc", func() {
125129
dsl.Attribute("tags_all", dsl.ArrayOf(dsl.String), "Tags to search with AND logic - matches resources that have all of these tags", func() {
126130
dsl.Example([]string{"governance", "security"})
127131
})
132+
dsl.Attribute("filters", dsl.ArrayOf(dsl.String), "Direct field filters with term clauses on data fields - format: 'field:value' (e.g., 'status:active'). Fields are automatically prefixed with 'data.'", func() {
133+
dsl.Example([]string{"status:active", "priority:high"})
134+
})
128135
dsl.Required("bearer_token", "version")
129136
})
130137

@@ -149,6 +156,7 @@ var _ = dsl.Service("query-svc", func() {
149156
dsl.Param("type")
150157
dsl.Param("tags")
151158
dsl.Param("tags_all")
159+
dsl.Param("filters")
152160
dsl.Header("bearer_token:Authorization")
153161
dsl.Response(dsl.StatusOK, func() {
154162
dsl.Header("cache_control:Cache-Control")

gen/http/cli/lfx_v2_query_service/cli.go

Lines changed: 17 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)