Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Issue 3545] relative date search #3640

Merged
merged 18 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,18 @@ components:
- string
- 'null'
format: date
start_date_relative:
type:
- integer
- 'null'
minimum: -1000000
maximum: 1000000
end_date_relative:
type:
- integer
- 'null'
minimum: -1000000
maximum: 1000000
CloseDateFilterV1:
type: object
properties:
Expand All @@ -1340,6 +1352,18 @@ components:
- string
- 'null'
format: date
start_date_relative:
type:
- integer
- 'null'
minimum: -1000000
maximum: 1000000
end_date_relative:
type:
- integer
- 'null'
minimum: -1000000
maximum: 1000000
OpportunitySearchFilterV1:
type: object
properties:
Expand Down
26 changes: 20 additions & 6 deletions api/src/adapters/search/opensearch_query_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,19 @@ def filter_int_range(
self.filters.append({"range": {field: range_filter}})
return self

def adjust_date_format(self, in_date: datetime.date | int | None) -> str | None:
if in_date is None:
return None
if isinstance(in_date, int):
return f"now{in_date:+}d"

return in_date.isoformat()

def filter_date_range(
self, field: str, start_date: datetime.date | None, end_date: datetime.date | None
self,
field: str,
start_date: datetime.date | int | None,
end_date: datetime.date | int | None,
) -> typing.Self:
"""
For a given field, filter results to a range of dates.
Expand All @@ -207,13 +218,16 @@ def filter_date_range(
a binary filter on the overall results.
"""
if start_date is None and end_date is None:
raise ValueError("Cannot use date range filter if both start and end are None")
raise ValueError("Cannot use date range filter if both start and end dates are None")

start_date_str = self.adjust_date_format(start_date)
end_date_str = self.adjust_date_format(end_date)

range_filter = {}
if start_date is not None:
range_filter["gte"] = start_date.isoformat()
if end_date is not None:
range_filter["lte"] = end_date.isoformat()
if start_date_str is not None:
range_filter["gte"] = start_date_str
if end_date_str is not None:
range_filter["lte"] = end_date_str

self.filters.append({"range": {field: range_filter}})
return self
Expand Down
36 changes: 33 additions & 3 deletions api/src/api/schemas/search_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,14 @@ class DateSearchSchemaBuilder(BaseSearchSchemaBuilder):
def with_date_range(self) -> "DateSearchSchemaBuilder":
self.schema_fields["start_date"] = fields.Date(allow_none=True)
self.schema_fields["end_date"] = fields.Date(allow_none=True)

self.schema_fields["start_date_relative"] = fields.Integer(
allow_none=True, validate=[validators.Range(min=-1000000, max=1000000)]
)
self.schema_fields["end_date_relative"] = fields.Integer(
allow_none=True, validate=[validators.Range(min=-1000000, max=1000000)]
)

self._with_date_range_validator()

return self
Expand All @@ -302,16 +310,38 @@ def _with_date_range_validator(self) -> "DateSearchSchemaBuilder":
# rules that go across fields in the validation
@validates_schema
def validate_date_range(_: Any, data: dict, **kwargs: Any) -> None:

start_date = data.get("start_date", None)
end_date = data.get("end_date", None)

# Error if start and end date are None (either explicitly set, or because they are missing)
if start_date is None and end_date is None:
start_date_relative = data.get("start_date_relative", None)
end_date_relative = data.get("end_date_relative", None)

# Error if both relative date and absolute date provided for either start or end date
if ("start_date" in data and "start_date_relative" in data) or (
"end_date" in data and "end_date_relative" in data
):
raise ValidationError(
[
MarshmallowErrorContainer(
ValidationErrorType.INVALID,
"Cannot have both absolute and relative start/end date.",
)
]
)

# Error if both start and end date for either relative or absolute date are None (either explicitly set, or because they are missing)
if (
start_date is None
and end_date is None
and start_date_relative is None
and end_date_relative is None
):
raise ValidationError(
[
MarshmallowErrorContainer(
ValidationErrorType.REQUIRED,
"At least one of start_date or end_date must be provided.",
"At least one of start_date/start_date_relative or end_date/end_date_relative must be provided.",
)
]
)
Expand Down
2 changes: 2 additions & 0 deletions api/src/search/search_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ class IntSearchFilter(BaseModel):
class DateSearchFilter(BaseModel):
start_date: date | None = None
end_date: date | None = None
start_date_relative: int | None = None
end_date_relative: int | None = None
16 changes: 15 additions & 1 deletion api/src/services/opportunities_v1/search_opportunities.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,21 @@ def _add_search_filters(
builder.filter_int_range(field_name, field_filters.min, field_filters.max)

elif isinstance(field_filters, DateSearchFilter):
builder.filter_date_range(field_name, field_filters.start_date, field_filters.end_date)
start_date = (
field_filters.start_date
if field_filters.start_date
else field_filters.start_date_relative
)
end_date = (
field_filters.end_date
if field_filters.end_date
else field_filters.end_date_relative
)
builder.filter_date_range(
field_name,
start_date,
end_date,
)


def _add_aggregations(builder: search.SearchQueryBuilder) -> None:
Expand Down
38 changes: 33 additions & 5 deletions api/tests/src/adapters/search/test_opensearch_query_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,19 +390,32 @@ def test_query_builder_filter_terms(
"start_date,end_date,expected_results",
[
# Date range that will include all results
# Absolute
(date(1900, 1, 1), date(2050, 1, 1), FULL_DATA),
# Relative
(-45656, 9131, FULL_DATA),
# Start only date range that will get all results
# Absolute
(date(1950, 1, 1), None, FULL_DATA),
# Relative
(-45656, None, FULL_DATA),
# End only date range that will get all results
# Absolute
(None, date(2025, 1, 1), FULL_DATA),
# Relative
(None, 9131, FULL_DATA),
# Range that filters to just oldest
(
date(1950, 1, 1),
date(1960, 1, 1),
[FELLOWSHIP_OF_THE_RING, TWO_TOWERS, RETURN_OF_THE_KING],
),
# Unbounded range for oldest few
(None, date(1990, 1, 1), [FELLOWSHIP_OF_THE_RING, TWO_TOWERS, RETURN_OF_THE_KING]),
(
None,
date(1990, 1, 1),
[FELLOWSHIP_OF_THE_RING, TWO_TOWERS, RETURN_OF_THE_KING],
),
# Unbounded range for newest few
(date(2011, 8, 1), None, [WORDS_OF_RADIANCE, OATHBRINGER, RHYTHM_OF_WAR]),
# Selecting a few in the middle
Expand All @@ -412,13 +425,24 @@ def test_query_builder_filter_terms(
[WAY_OF_KINGS, FEAST_FOR_CROWS, DANCE_WITH_DRAGONS],
),
# Exact date
# Absolute
(date(1954, 7, 29), date(1954, 7, 29), [FELLOWSHIP_OF_THE_RING]),
# Relative
(-25747, -25747, []),
# None fetched in range
# Absolute
(date(1981, 1, 1), date(1989, 1, 1), []),
# Relative
(-16093, -13171, []),
],
)
def test_query_builder_filter_date_range(
self, search_client, search_index, start_date, end_date, expected_results
self,
search_client,
search_index,
start_date,
end_date,
expected_results,
):
builder = (
SearchQueryBuilder()
Expand All @@ -428,9 +452,13 @@ def test_query_builder_filter_date_range(

expected_ranges = {}
if start_date is not None:
expected_ranges["gte"] = start_date.isoformat()
expected_ranges["gte"] = (
f"now{start_date:+}d" if isinstance(start_date, int) else start_date.isoformat()
)
if end_date is not None:
expected_ranges["lte"] = end_date.isoformat()
expected_ranges["lte"] = (
f"now{end_date:+}d" if isinstance(end_date, int) else end_date.isoformat()
)

expected_query = {
"size": 25,
Expand Down Expand Up @@ -509,7 +537,7 @@ def test_filter_int_range_both_none(self):
with pytest.raises(ValueError, match="Cannot use int range filter"):
SearchQueryBuilder().filter_int_range("test_field", None, None)

def test_filter_date_range_both_none(self):
def test_filter_date_range_all_none(self):
with pytest.raises(ValueError, match="Cannot use date range filter"):
SearchQueryBuilder().filter_date_range("test_field", None, None)

Expand Down
Loading