Skip to content

Commit ae5588e

Browse files
authored
Add free-text search extension (#227)
**Description:** Adding the free-text search extension. Related to stac-utils/stac-fastapi#655 **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent ace0c7a commit ae5588e

File tree

8 files changed

+90
-0
lines changed

8 files changed

+90
-0
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10+
### Added
11+
- Added support for FreeTextExtension. [#227](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/227)
12+
1013
### Changed
1114
- Support escaped backslashes in CQL2 `LIKE` queries, and reject invalid (or incomplete) escape sequences. [#286](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/286)
1215

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Core client."""
2+
23
import logging
34
from datetime import datetime as datetime_type
45
from datetime import timezone
@@ -456,6 +457,7 @@ async def get_search(
456457
token: Optional[str] = None,
457458
fields: Optional[List[str]] = None,
458459
sortby: Optional[str] = None,
460+
q: Optional[List[str]] = None,
459461
intersects: Optional[str] = None,
460462
filter: Optional[str] = None,
461463
filter_lang: Optional[str] = None,
@@ -473,6 +475,7 @@ async def get_search(
473475
token (Optional[str]): Access token to use when searching the catalog.
474476
fields (Optional[List[str]]): Fields to include or exclude from the results.
475477
sortby (Optional[str]): Sorting options for the results.
478+
q (Optional[List[str]]): Free text query to filter the results.
476479
intersects (Optional[str]): GeoJSON geometry to search in.
477480
kwargs: Additional parameters to be passed to the API.
478481
@@ -489,6 +492,7 @@ async def get_search(
489492
"limit": limit,
490493
"token": token,
491494
"query": orjson.loads(query) if query else query,
495+
"q": q,
492496
}
493497

494498
if datetime:
@@ -599,6 +603,15 @@ async def post_search(
599603
status_code=400, detail=f"Error with cql2_json filter: {e}"
600604
)
601605

606+
if hasattr(search_request, "q"):
607+
free_text_queries = getattr(search_request, "q", None)
608+
try:
609+
search = self.database.apply_free_text_filter(search, free_text_queries)
610+
except Exception as e:
611+
raise HTTPException(
612+
status_code=400, detail=f"Error with free text query: {e}"
613+
)
614+
602615
sort = None
603616
if search_request.sortby:
604617
sort = self.database.populate_sort(search_request.sortby)

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from stac_fastapi.extensions.core import (
2929
AggregationExtension,
3030
FilterExtension,
31+
FreeTextExtension,
3132
SortExtension,
3233
TokenPaginationExtension,
3334
TransactionExtension,
@@ -71,6 +72,7 @@
7172
SortExtension(),
7273
TokenPaginationExtension(),
7374
filter_extension,
75+
FreeTextExtension(),
7476
]
7577

7678
extensions = [aggregation_extension] + search_extensions

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Database logic."""
2+
23
import asyncio
34
import logging
45
import os
@@ -509,6 +510,17 @@ def apply_stacql_filter(search: Search, op: str, field: str, value: float):
509510

510511
return search
511512

513+
@staticmethod
514+
def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str]]):
515+
"""Database logic to perform query for search endpoint."""
516+
if free_text_queries is not None:
517+
free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries)
518+
search = search.query(
519+
"query_string", query=f'properties.\\*:"{free_text_query_string}"'
520+
)
521+
522+
return search
523+
512524
@staticmethod
513525
def apply_cql2_filter(search: Search, _filter: Optional[Dict[str, Any]]):
514526
"""

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from stac_fastapi.extensions.core import (
2323
AggregationExtension,
2424
FilterExtension,
25+
FreeTextExtension,
2526
SortExtension,
2627
TokenPaginationExtension,
2728
TransactionExtension,
@@ -71,6 +72,7 @@
7172
SortExtension(),
7273
TokenPaginationExtension(),
7374
filter_extension,
75+
FreeTextExtension(),
7476
]
7577

7678
extensions = [aggregation_extension] + search_extensions

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Database logic."""
2+
23
import asyncio
34
import logging
45
import os
@@ -426,6 +427,17 @@ def apply_collections_filter(search: Search, collection_ids: List[str]):
426427
"""Database logic to search a list of STAC collection ids."""
427428
return search.filter("terms", collection=collection_ids)
428429

430+
@staticmethod
431+
def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str]]):
432+
"""Database logic to perform query for search endpoint."""
433+
if free_text_queries is not None:
434+
free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries)
435+
search = search.query(
436+
"query_string", query=f'properties.\\*:"{free_text_query_string}"'
437+
)
438+
439+
return search
440+
429441
@staticmethod
430442
def apply_datetime_filter(search: Search, datetime_search):
431443
"""Apply a filter to search based on datetime field.

stac_fastapi/tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
AggregationExtension,
5050
FieldsExtension,
5151
FilterExtension,
52+
FreeTextExtension,
5253
SortExtension,
5354
TokenPaginationExtension,
5455
TransactionExtension,
@@ -215,6 +216,7 @@ async def app():
215216
QueryExtension(),
216217
TokenPaginationExtension(),
217218
FilterExtension(),
219+
FreeTextExtension(),
218220
]
219221

220222
extensions = [aggregation_extension] + search_extensions
@@ -301,6 +303,7 @@ async def app_basic_auth():
301303
QueryExtension(),
302304
TokenPaginationExtension(),
303305
FilterExtension(),
306+
FreeTextExtension(),
304307
]
305308

306309
extensions = [aggregation_extension] + search_extensions
@@ -380,6 +383,7 @@ async def route_dependencies_app():
380383
QueryExtension(),
381384
TokenPaginationExtension(),
382385
FilterExtension(),
386+
FreeTextExtension(),
383387
]
384388

385389
post_request_model = create_post_request_model(extensions)

stac_fastapi/tests/resources/test_item.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,48 @@ async def test_item_search_properties_field(app_client):
533533
assert len(resp_json["features"]) == 0
534534

535535

536+
@pytest.mark.asyncio
537+
async def test_item_search_free_text_extension(app_client, txn_client, ctx):
538+
"""Test POST search indexed field with q parameter (free-text)"""
539+
first_item = ctx.item
540+
541+
second_item = dict(first_item)
542+
second_item["id"] = "second-item"
543+
second_item["properties"]["ft_field1"] = "hello"
544+
545+
await create_item(txn_client, second_item)
546+
547+
params = {"q": ["hello"]}
548+
resp = await app_client.post("/search", json=params)
549+
assert resp.status_code == 200
550+
resp_json = resp.json()
551+
assert len(resp_json["features"]) == 1
552+
553+
554+
@pytest.mark.asyncio
555+
async def test_item_search_free_text_extension_or_query(app_client, txn_client, ctx):
556+
"""Test POST search indexed field with q parameter with multiple terms (free-text)"""
557+
first_item = ctx.item
558+
559+
second_item = dict(first_item)
560+
second_item["id"] = "second-item"
561+
second_item["properties"]["ft_field1"] = "hello"
562+
second_item["properties"]["ft_field2"] = "world"
563+
564+
await create_item(txn_client, second_item)
565+
566+
third_item = dict(first_item)
567+
third_item["id"] = "third-item"
568+
third_item["properties"]["ft_field1"] = "world"
569+
await create_item(txn_client, third_item)
570+
571+
params = {"q": ["hello", "world"]}
572+
resp = await app_client.post("/search", json=params)
573+
assert resp.status_code == 200
574+
resp_json = resp.json()
575+
assert len(resp_json["features"]) == 2
576+
577+
536578
@pytest.mark.asyncio
537579
async def test_item_search_get_query_extension(app_client, ctx):
538580
"""Test GET search with JSONB query (query extension)"""

0 commit comments

Comments
 (0)