Skip to content

Commit d280dd3

Browse files
authored
Merge pull request #29 from linuxfoundation/andrest50/query-date-range
[LFXV2-919] Add date range filtering support to query service endpoints
2 parents 3e45fc4 + 8f61964 commit d280dd3

File tree

17 files changed

+749
-19
lines changed

17 files changed

+749
-19
lines changed

CLAUDE.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,50 @@ Environment variables control implementation selection:
111111
- Integration tests can switch between real and mock implementations
112112
- Test files follow `*_test.go` pattern alongside implementation files
113113

114+
<<<<<<< HEAD
115+
## API Features
116+
117+
### Date Range Filtering
118+
119+
The query service supports filtering resources by date ranges on fields within the `data` object.
120+
121+
**Query Parameters:**
122+
123+
- `date_field` (string, optional): Date field to filter on (automatically prefixed with `"data."`)
124+
- `date_from` (string, optional): Start date (inclusive, gte operator)
125+
- `date_to` (string, optional): End date (inclusive, lte operator)
126+
127+
**Supported Date Formats:**
128+
129+
1. **ISO 8601 datetime**: `2025-01-10T15:30:00Z` (time used as provided)
130+
2. **Date-only**: `2025-01-10` (converted to start/end of day UTC)
131+
- `date_from``2025-01-10T00:00:00Z` (start of day)
132+
- `date_to``2025-01-10T23:59:59Z` (end of day)
133+
134+
**Examples:**
135+
136+
```bash
137+
# Date range with date-only format
138+
GET /query/resources?v=1&date_field=updated_at&date_from=2025-01-10&date_to=2025-01-28
139+
140+
# Date range with ISO 8601 format
141+
GET /query/resources?v=1&date_field=created_at&date_from=2025-01-10T15:30:00Z&date_to=2025-01-28T18:45:00Z
142+
143+
# Open-ended range (only start date)
144+
GET /query/resources?v=1&date_field=created_at&date_from=2025-01-01
145+
146+
# Combined with other filters
147+
GET /query/resources?v=1&type=project&tags=active&date_field=updated_at&date_from=2025-01-01&date_to=2025-03-31
148+
```
149+
150+
**Implementation Details:**
151+
152+
- Date parsing logic: `cmd/service/converters.go` (`parseDateFilter()` function)
153+
- Domain model: `internal/domain/model/search_criteria.go` (DateField, DateFrom, DateTo)
154+
- OpenSearch query: `internal/infrastructure/opensearch/template.go` (range query with gte/lte)
155+
- API design: `design/query-svc.go` (Goa design specification)
156+
- Test coverage: `cmd/service/converters_test.go` (17 comprehensive test cases)
157+
=======
114158
## CEL Filter Feature
115159

116160
The service supports Common Expression Language (CEL) filtering for post-query resource filtering.
@@ -183,3 +227,4 @@ service := service.NewResourceSearch(mockSearcher, mockAccessChecker, mockFilter
183227
### Important Limitations
184228

185229
**Pagination**: CEL filters apply only to results from each OpenSearch page. If the target resource is not in the first page of OpenSearch results, it won't be found even if it matches the CEL filter. Always use specific primary search criteria (`type`, `name`, `parent`) to narrow OpenSearch results first.
230+
>>>>>>> 3e45fc4d33aba656a5abe1c3df0d3f2bd0fd6be7

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,17 @@ Authorization: Bearer <jwt_token>
214214
- `name`: Resource name or alias (supports typeahead search)
215215
- `type`: Resource type to filter by
216216
- `parent`: Parent resource for hierarchical queries
217+
<<<<<<< HEAD
218+
- `tags`: Array of tags to filter by (OR logic - matches resources with any of these tags)
219+
- `tags_all`: Array of tags to filter by (AND logic - matches resources that have all of these tags)
220+
- `date_field`: Date field to filter on (within data object) - used with date_from and/or date_to
221+
- `date_from`: Start date (inclusive). Format: ISO 8601 datetime or date-only (YYYY-MM-DD). Date-only uses start of day UTC
222+
- `date_to`: End date (inclusive). Format: ISO 8601 datetime or date-only (YYYY-MM-DD). Date-only uses end of day UTC
223+
=======
217224
- `tags`: Array of tags to filter by (OR logic)
218225
- `tags_all`: Array of tags where all must match (AND logic)
219226
- `cel_filter`: CEL expression for advanced post-query filtering (see [CEL Filter](#cel-filter) section)
227+
>>>>>>> 3e45fc4d33aba656a5abe1c3df0d3f2bd0fd6be7
220228
- `sort`: Sort order (name_asc, name_desc, updated_asc, updated_desc)
221229
- `page_token`: Pagination token
222230
- `v`: API version (required)
@@ -241,6 +249,46 @@ Authorization: Bearer <jwt_token>
241249
}
242250
```
243251

252+
<<<<<<< HEAD
253+
**Date Range Filtering Examples:**
254+
255+
Filter resources updated between two dates (date-only format):
256+
257+
```bash
258+
GET /query/resources?v=1&date_field=updated_at&date_from=2025-01-10&date_to=2025-01-28
259+
Authorization: Bearer <jwt_token>
260+
```
261+
262+
Filter resources with precise datetime filtering (ISO 8601 format):
263+
264+
```bash
265+
GET /query/resources?v=1&date_field=created_at&date_from=2025-01-10T15:30:00Z&date_to=2025-01-28T18:45:00Z
266+
Authorization: Bearer <jwt_token>
267+
```
268+
269+
Filter resources created after a specific date (open-ended range):
270+
271+
```bash
272+
GET /query/resources?v=1&date_field=created_at&date_from=2025-01-01
273+
Authorization: Bearer <jwt_token>
274+
```
275+
276+
Combine date filtering with other parameters:
277+
278+
```bash
279+
GET /query/resources?v=1&type=project&tags=active&date_field=updated_at&date_from=2025-01-01&date_to=2025-03-31
280+
Authorization: Bearer <jwt_token>
281+
```
282+
283+
**Date Format Notes:**
284+
285+
- **ISO 8601 datetime format**: `2025-01-10T15:30:00Z` (time is used as provided)
286+
- **Date-only format**: `2025-01-10` (automatically converted to start/end of day UTC)
287+
- For `date_from`: Converts to `2025-01-10T00:00:00Z` (start of day)
288+
- For `date_to`: Converts to `2025-01-10T23:59:59Z` (end of day)
289+
- All dates are inclusive (uses `gte` and `lte` operators)
290+
- The `date_field` parameter is automatically prefixed with `"data."` to scope to the resource's data object
291+
=======
244292
#### CEL Filter
245293

246294
The `cel_filter` query parameter enables advanced filtering of search results using Common Expression Language (CEL). CEL is a non-Turing complete expression language designed for safe, fast evaluation of expressions in performance-critical applications.
@@ -353,6 +401,7 @@ Invalid CEL expressions return a 400 Bad Request with details:
353401
"error": "filter expression failed: ERROR: <input>:1:6: Syntax error: mismatched input 'invalid' expecting {'[', '{', '(', '.', '-', '!', 'true', 'false', 'null', NUM_FLOAT, NUM_INT, NUM_UINT, STRING, BYTES, IDENTIFIER}"
354402
}
355403
```
404+
>>>>>>> 3e45fc4d33aba656a5abe1c3df0d3f2bd0fd6be7
356405
357406
#### Organization Search API
358407

cmd/service/converters.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"fmt"
99
"log/slog"
10+
"time"
1011
"strings"
1112

1213
querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc"
@@ -16,6 +17,42 @@ import (
1617
"github.com/linuxfoundation/lfx-v2-query-service/pkg/paging"
1718
)
1819

20+
// parseDateFilter parses a date string in ISO 8601 datetime or date-only format
21+
// and returns it normalized for OpenSearch range queries.
22+
// Date-only format (YYYY-MM-DD) is converted to:
23+
// - Start of day (00:00:00 UTC) for date_from
24+
// - End of day (23:59:59 UTC) for date_to
25+
func parseDateFilter(dateStr string, isEndDate bool) (string, error) {
26+
if dateStr == "" {
27+
return "", nil
28+
}
29+
30+
// Try parsing as ISO 8601 datetime first (e.g., 2025-01-10T15:30:00Z)
31+
t, err := time.Parse(time.RFC3339, dateStr)
32+
if err == nil {
33+
// Already in datetime format, return as-is
34+
return t.Format(time.RFC3339), nil
35+
}
36+
37+
// Try parsing as date-only (e.g., 2025-01-10)
38+
t, err = time.Parse("2006-01-02", dateStr)
39+
if err != nil {
40+
return "", fmt.Errorf("invalid date format '%s': must be ISO 8601 datetime (2006-01-02T15:04:05Z) or date-only (2006-01-02)", dateStr)
41+
}
42+
43+
// Convert date-only to datetime
44+
if isEndDate {
45+
// For end dates, use end of day (23:59:59 UTC)
46+
// Note: Using 23:59:59 instead of 23:59:59.999 for simplicity and OpenSearch compatibility
47+
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC)
48+
} else {
49+
// For start dates, use start of day (00:00:00 UTC)
50+
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
51+
}
52+
53+
return t.Format(time.RFC3339), nil
54+
}
55+
1956
// parseFilters parses filter strings in "field:value" format
2057
// All fields are automatically prefixed with "data." to filter only within the data object
2158
func parseFilters(filters []string) ([]model.FieldFilter, error) {
@@ -91,6 +128,40 @@ func (s *querySvcsrvc) payloadToCriteria(ctx context.Context, p *querysvc.QueryR
91128
)
92129
}
93130

131+
// Validate date filtering parameters
132+
if (p.DateFrom != nil || p.DateTo != nil) && p.DateField == nil {
133+
err := fmt.Errorf("date_field is required when using date_from or date_to")
134+
slog.ErrorContext(ctx, "invalid date filter parameters", "error", err)
135+
return criteria, wrapError(ctx, err)
136+
}
137+
138+
// Handle date filtering parameters
139+
if p.DateField != nil {
140+
// Auto-prefix with "data." to scope to data object
141+
prefixedField := "data." + *p.DateField
142+
criteria.DateField = &prefixedField
143+
144+
// Parse and normalize date_from
145+
if p.DateFrom != nil {
146+
normalizedFrom, err := parseDateFilter(*p.DateFrom, false)
147+
if err != nil {
148+
slog.ErrorContext(ctx, "invalid date_from format", "error", err, "date_from", *p.DateFrom)
149+
return criteria, wrapError(ctx, err)
150+
}
151+
criteria.DateFrom = &normalizedFrom
152+
}
153+
154+
// Parse and normalize date_to
155+
if p.DateTo != nil {
156+
normalizedTo, err := parseDateFilter(*p.DateTo, true)
157+
if err != nil {
158+
slog.ErrorContext(ctx, "invalid date_to format", "error", err, "date_to", *p.DateTo)
159+
return criteria, wrapError(ctx, err)
160+
}
161+
criteria.DateTo = &normalizedTo
162+
}
163+
}
164+
94165
return criteria, nil
95166
}
96167

@@ -146,6 +217,36 @@ func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResou
146217
criteria.ParentRef = payload.Parent
147218
}
148219

220+
// Validate date filtering parameters
221+
if (payload.DateFrom != nil || payload.DateTo != nil) && payload.DateField == nil {
222+
return criteria, fmt.Errorf("date_field is required when using date_from or date_to")
223+
}
224+
225+
// Handle date filtering parameters
226+
if payload.DateField != nil {
227+
// Auto-prefix with "data." to scope to data object
228+
prefixedField := "data." + *payload.DateField
229+
criteria.DateField = &prefixedField
230+
231+
// Parse and normalize date_from
232+
if payload.DateFrom != nil {
233+
normalizedFrom, err := parseDateFilter(*payload.DateFrom, false)
234+
if err != nil {
235+
return criteria, fmt.Errorf("invalid date_from: %w", err)
236+
}
237+
criteria.DateFrom = &normalizedFrom
238+
}
239+
240+
// Parse and normalize date_to
241+
if payload.DateTo != nil {
242+
normalizedTo, err := parseDateFilter(*payload.DateTo, true)
243+
if err != nil {
244+
return criteria, fmt.Errorf("invalid date_to: %w", err)
245+
}
246+
criteria.DateTo = &normalizedTo
247+
}
248+
}
249+
149250
return criteria, nil
150251
}
151252

@@ -182,6 +283,36 @@ func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.Query
182283
criteria.ParentRef = payload.Parent
183284
}
184285

286+
// Validate date filtering parameters
287+
if (payload.DateFrom != nil || payload.DateTo != nil) && payload.DateField == nil {
288+
return criteria, fmt.Errorf("date_field is required when using date_from or date_to")
289+
}
290+
291+
// Handle date filtering parameters
292+
if payload.DateField != nil {
293+
// Auto-prefix with "data." to scope to data object
294+
prefixedField := "data." + *payload.DateField
295+
criteria.DateField = &prefixedField
296+
297+
// Parse and normalize date_from
298+
if payload.DateFrom != nil {
299+
normalizedFrom, err := parseDateFilter(*payload.DateFrom, false)
300+
if err != nil {
301+
return criteria, fmt.Errorf("invalid date_from: %w", err)
302+
}
303+
criteria.DateFrom = &normalizedFrom
304+
}
305+
306+
// Parse and normalize date_to
307+
if payload.DateTo != nil {
308+
normalizedTo, err := parseDateFilter(*payload.DateTo, true)
309+
if err != nil {
310+
return criteria, fmt.Errorf("invalid date_to: %w", err)
311+
}
312+
criteria.DateTo = &normalizedTo
313+
}
314+
}
315+
185316
return criteria, nil
186317
}
187318

0 commit comments

Comments
 (0)