Skip to content

Commit 95d2f27

Browse files
LIKE wildcard search
1 parent 61c01cb commit 95d2f27

File tree

5 files changed

+160
-2
lines changed

5 files changed

+160
-2
lines changed

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@
1919
TokenPaginationExtension,
2020
TransactionExtension,
2121
)
22+
2223
from stac_fastapi.extensions.third_party import BulkTransactionExtension
2324

2425
settings = ElasticsearchSettings()
2526
session = Session.create_from_settings(settings)
2627

28+
filter_extension = FilterExtension(client=EsAsyncBaseFiltersClient())
29+
filter_extension.conformance_classes.append("http://www.opengis.net/spec/cql2/1.0/req/advanced-comparison-operators")
30+
2731
extensions = [
2832
TransactionExtension(client=TransactionsClient(session=session), settings=settings),
2933
BulkTransactionExtension(client=BulkTransactionsClient(session=session)),
@@ -32,7 +36,7 @@
3236
SortExtension(),
3337
TokenPaginationExtension(),
3438
ContextExtension(),
35-
FilterExtension(client=EsAsyncBaseFiltersClient()),
39+
filter_extension
3640
]
3741

3842
post_request_model = create_post_request_model(extensions)

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/extensions/filter.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ def to_es(self):
7878
)
7979

8080

81+
class AdvancedComparisonOp(str, Enum):
82+
"""Advanced Comparison operator.
83+
84+
CQL2 advanced comparison operator like (~).
85+
"""
86+
87+
like = "like"
88+
89+
8190
class SpatialIntersectsOp(str, Enum):
8291
"""Spatial intersections operator s_intersects."""
8392

@@ -152,7 +161,7 @@ def validate(cls, v):
152161
class Clause(BaseModel):
153162
"""Filter extension clause."""
154163

155-
op: Union[LogicalOp, ComparisonOp, SpatialIntersectsOp]
164+
op: Union[LogicalOp, ComparisonOp, AdvancedComparisonOp, SpatialIntersectsOp]
156165
args: List[Arg]
157166

158167
def to_es(self):
@@ -171,6 +180,16 @@ def to_es(self):
171180
"must_not": [{"term": {to_es(self.args[0]): to_es(self.args[1])}}]
172181
}
173182
}
183+
elif self.op == AdvancedComparisonOp.like:
184+
return {
185+
"wildcard": {
186+
to_es(self.args[0]): {
187+
"value": cql2_like_to_es(str(to_es(self.args[1]))),
188+
"boost": 1.0,
189+
"case_insensitive": "true"
190+
}
191+
}
192+
}
174193
elif (
175194
self.op == ComparisonOp.lt
176195
or self.op == ComparisonOp.lte
@@ -210,3 +229,25 @@ def to_es(arg: Arg):
210229
return arg
211230
else:
212231
raise RuntimeError(f"unknown arg {repr(arg)}")
232+
233+
234+
def cql2_like_to_es(input_string):
235+
"""
236+
Convert arugument in CQL2 ('_' and '%') to Elasticsearch wildcard operators ('?' and '*', respectively). Handle escape characters and
237+
handle Elasticsearch wildcards directly.
238+
"""
239+
es_string = ""
240+
escape = False
241+
242+
for char in input_string:
243+
if char == "\\":
244+
escape = True
245+
elif char == '_' and not escape:
246+
es_string += '?'
247+
elif char == '%' and not escape:
248+
es_string += '*'
249+
else:
250+
es_string += char
251+
escape = False
252+
253+
return es_string
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"op": "like",
3+
"args": [
4+
{
5+
"property": "scene_id"
6+
},
7+
"LC82030282019133%"
8+
]
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"op": "like",
3+
"args": [
4+
{
5+
"property": "scene_id"
6+
},
7+
"LC82030282019133LGN0_"
8+
]
9+
}
10+

stac_fastapi/elasticsearch/tests/extensions/test_filter.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,97 @@ async def test_search_filter_extension_floats_post(app_client, ctx):
213213

214214
assert resp.status_code == 200
215215
assert len(resp.json()["features"]) == 1
216+
217+
218+
@pytest.mark.asyncio
219+
async def test_search_filter_extension_wildcard_cql2(app_client, ctx):
220+
single_char = ctx.item["id"][:-1] + "_"
221+
multi_char = ctx.item["id"][:-3] + "%"
222+
223+
params = {
224+
"filter": {
225+
"op": "and",
226+
"args": [
227+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
228+
{
229+
"op": "like",
230+
"args": [
231+
{"property": "id"},
232+
single_char,
233+
],
234+
},
235+
{
236+
"op": "like",
237+
"args": [
238+
{"property": "id"},
239+
multi_char,
240+
],
241+
},
242+
],
243+
}
244+
}
245+
246+
resp = await app_client.post("/search", json=params)
247+
248+
assert resp.status_code == 200
249+
assert len(resp.json()["features"]) == 1
250+
251+
252+
@pytest.mark.asyncio
253+
async def test_search_filter_extension_wildcard_es(app_client, ctx):
254+
single_char = ctx.item["id"][:-1] + "?"
255+
multi_char = ctx.item["id"][:-3] + "*"
256+
257+
params = {
258+
"filter": {
259+
"op": "and",
260+
"args": [
261+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
262+
{
263+
"op": "like",
264+
"args": [
265+
{"property": "id"},
266+
single_char,
267+
],
268+
},
269+
{
270+
"op": "like",
271+
"args": [
272+
{"property": "id"},
273+
multi_char,
274+
],
275+
},
276+
],
277+
}
278+
}
279+
280+
resp = await app_client.post("/search", json=params)
281+
282+
assert resp.status_code == 200
283+
assert len(resp.json()["features"]) == 1
284+
285+
286+
@pytest.mark.asyncio
287+
async def test_search_filter_extension_escape_chars(app_client, ctx):
288+
esc_chars = ctx.item["properties"]["landsat:product_id"].replace("_", "\_")[:-1] + "_"
289+
290+
params = {
291+
"filter": {
292+
"op": "and",
293+
"args": [
294+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
295+
{
296+
"op": "like",
297+
"args": [
298+
{"property": "properties.landsat:product_id"},
299+
esc_chars,
300+
],
301+
}
302+
],
303+
}
304+
}
305+
306+
resp = await app_client.post("/search", json=params)
307+
308+
assert resp.status_code == 200
309+
assert len(resp.json()["features"]) == 1

0 commit comments

Comments
 (0)