Skip to content

Commit 3ffe9d5

Browse files
committed
Move extension replacement to ElasticPath.
1 parent b532058 commit 3ffe9d5

File tree

3 files changed

+136
-44
lines changed

3 files changed

+136
-44
lines changed

stac_fastapi/core/stac_fastapi/core/models/patch.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,62 @@
11
"""patch helpers."""
22

3-
from typing import Any, Optional, Union
3+
import re
4+
from typing import Any, Dict, Optional, Union
45

56
from pydantic import BaseModel, computed_field, model_validator
67

8+
regex = re.compile(r"([^.' ]*:[^.' ]*)\.?")
9+
10+
11+
class ESCommandSet:
12+
"""Uses dictionary keys to behaviour of ordered set.
13+
14+
Yields:
15+
str: Elasticsearch commands
16+
"""
17+
18+
dict_: Dict[str, None] = {}
19+
20+
def add(self, value: str):
21+
"""Add command.
22+
23+
Args:
24+
value (str): value to be added
25+
"""
26+
self.dict_[value] = None
27+
28+
def remove(self, value: str):
29+
"""Remove command.
30+
31+
Args:
32+
value (str): value to be removed
33+
"""
34+
del self.dict_[value]
35+
36+
def __iter__(self):
37+
"""Iterate Elasticsearch commands.
38+
39+
Yields:
40+
str: Elasticsearch command
41+
"""
42+
yield from self.dict_.keys()
43+
44+
45+
def to_es(string: str):
46+
"""Convert patch operation key to Elasticsearch key.
47+
48+
Args:
49+
string (str): string to be converted
50+
51+
Returns:
52+
_type_: converted string
53+
"""
54+
if matches := regex.findall(string):
55+
for match in set(matches):
56+
string = re.sub(rf"\.?{match}", f"['{match}']", string)
57+
58+
return string
59+
760

861
class ElasticPath(BaseModel):
962
"""Converts a JSON path to an Elasticsearch path.
@@ -17,6 +70,11 @@ class ElasticPath(BaseModel):
1770
nest: Optional[str] = None
1871
partition: Optional[str] = None
1972
key: Optional[str] = None
73+
74+
es_path: Optional[str] = None
75+
es_nest: Optional[str] = None
76+
es_key: Optional[str] = None
77+
2078
index_: Optional[int] = None
2179

2280
@model_validator(mode="before")
@@ -35,6 +93,10 @@ def validate_model(cls, data: Any):
3593
data["path"] = f"{data['nest']}[{data['index_']}]"
3694
data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".")
3795

96+
data["es_path"] = to_es(data["path"])
97+
data["es_nest"] = to_es(data["nest"])
98+
data["es_key"] = to_es(data["key"])
99+
38100
return data
39101

40102
@computed_field # type: ignore[misc]
@@ -43,7 +105,7 @@ def index(self) -> Union[int, str, None]:
43105
"""Compute location of path.
44106
45107
Returns:
46-
str: path location
108+
str: path index
47109
"""
48110
if self.index_ and self.index_ < 0:
49111

@@ -60,3 +122,30 @@ def location(self) -> str:
60122
str: path location
61123
"""
62124
return self.nest + self.partition + self.key
125+
126+
@computed_field # type: ignore[misc]
127+
@property
128+
def es_location(self) -> str:
129+
"""Compute location of path.
130+
131+
Returns:
132+
str: path location
133+
"""
134+
if self.es_key and ":" in self.es_key:
135+
return self.es_nest + self.es_key
136+
return self.es_nest + self.partition + self.es_key
137+
138+
@computed_field # type: ignore[misc]
139+
@property
140+
def variable_name(self) -> str:
141+
"""Variable name for scripting.
142+
143+
Returns:
144+
str: variable name
145+
"""
146+
if self.index is not None:
147+
return f"{self.location.replace('.','_').replace(':','_')}_{self.index}"
148+
149+
return (
150+
f"{self.nest.replace('.','_').replace(':','_')}_{self.key.replace(':','_')}"
151+
)

stac_fastapi/core/stac_fastapi/core/utilities.py

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
such as converting bounding boxes to polygon representations.
55
"""
66

7-
import re
87
from typing import Any, Dict, List, Optional, Set, Union
98

10-
from stac_fastapi.core.models.patch import ElasticPath
9+
from stac_fastapi.core.models.patch import ElasticPath, ESCommandSet
1110
from stac_fastapi.types.stac import (
1211
Item,
1312
PatchAddReplaceTest,
@@ -173,7 +172,7 @@ def merge_to_operations(data: Dict) -> List:
173172

174173

175174
def check_commands(
176-
commands: List[str],
175+
commands: ESCommandSet,
177176
op: str,
178177
path: ElasticPath,
179178
from_path: bool = False,
@@ -188,28 +187,28 @@ def check_commands(
188187
189188
"""
190189
if path.nest:
191-
commands.append(
190+
commands.add(
192191
f"if (!ctx._source.containsKey('{path.nest}'))"
193192
f"{{Debug.explain('{path.nest} does not exist');}}"
194193
)
195194

196195
if path.index or op in ["remove", "replace", "test"] or from_path:
197-
commands.append(
198-
f"if (!ctx._source.{path.nest}.containsKey('{path.key}'))"
196+
commands.add(
197+
f"if (!ctx._source.{path.es_nest}.containsKey('{path.key}'))"
199198
f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}"
200199
)
201200

202201
if from_path and path.index is not None:
203-
commands.append(
204-
f"if ((ctx._source.{path.location} instanceof ArrayList"
205-
f" && ctx._source.{path.location}.size() < {path.index})"
202+
commands.add(
203+
f"if ((ctx._source.{path.es_location} instanceof ArrayList"
204+
f" && ctx._source.{path.es_location}.size() < {path.index})"
206205
f" || (!(ctx._source.properties.hello instanceof ArrayList)"
207-
f" && !ctx._source.{path.location}.containsKey('{path.index}')))"
206+
f" && !ctx._source.{path.es_location}.containsKey('{path.index}')))"
208207
f"{{Debug.explain('{path.path} does not exist');}}"
209208
)
210209

211210

212-
def remove_commands(commands: List[str], path: ElasticPath) -> None:
211+
def remove_commands(commands: ESCommandSet, path: ElasticPath) -> None:
213212
"""Remove value at path.
214213
215214
Args:
@@ -218,14 +217,18 @@ def remove_commands(commands: List[str], path: ElasticPath) -> None:
218217
219218
"""
220219
if path.index is not None:
221-
commands.append(f"def temp = ctx._source.{path.location}.remove({path.index});")
220+
commands.add(
221+
f"def {path.variable_name} = ctx._source.{path.es_location}.remove({path.index});"
222+
)
222223

223224
else:
224-
commands.append(f"def temp = ctx._source.{path.nest}.remove('{path.key}');")
225+
commands.add(
226+
f"def {path.variable_name} = ctx._source.{path.es_nest}.remove('{path.key}');"
227+
)
225228

226229

227230
def add_commands(
228-
commands: List[str],
231+
commands: ESCommandSet,
229232
operation: PatchOperation,
230233
path: ElasticPath,
231234
from_path: ElasticPath,
@@ -239,23 +242,27 @@ def add_commands(
239242
240243
"""
241244
if from_path is not None:
242-
value = "temp" if operation.op == "move" else f"ctx._source.{from_path.path}"
245+
value = (
246+
from_path.variable_name
247+
if operation.op == "move"
248+
else f"ctx._source.{from_path.es_path}"
249+
)
243250
else:
244251
value = operation.json_value
245252

246253
if path.index is not None:
247-
commands.append(
248-
f"if (ctx._source.{path.location} instanceof ArrayList)"
249-
f"{{ctx._source.{path.location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}"
250-
f"else{{ctx._source.{path.path} = {value}}}"
254+
commands.add(
255+
f"if (ctx._source.{path.es_location} instanceof ArrayList)"
256+
f"{{ctx._source.{path.es_location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}"
257+
f"else{{ctx._source.{path.es_path} = {value}}}"
251258
)
252259

253260
else:
254-
commands.append(f"ctx._source.{path.path} = {value};")
261+
commands.add(f"ctx._source.{path.es_path} = {value};")
255262

256263

257264
def test_commands(
258-
commands: List[str], operation: PatchOperation, path: ElasticPath
265+
commands: ESCommandSet, operation: PatchOperation, path: ElasticPath
259266
) -> None:
260267
"""Test value at path.
261268
@@ -264,10 +271,10 @@ def test_commands(
264271
operation (PatchOperation): operation to run
265272
path (ElasticPath): path for value to be tested
266273
"""
267-
commands.append(
268-
f"if (ctx._source.{path.location} != {operation.json_value})"
269-
f"{{Debug.explain('Test failed for: {path.path} | "
270-
f"{operation.json_value} != ' + ctx._source.{path.location});}}"
274+
commands.add(
275+
f"if (ctx._source.{path.es_path} != {operation.json_value})"
276+
f"{{Debug.explain('Test failed `{path.path}` | "
277+
f"{operation.json_value} != ' + ctx._source.{path.es_path});}}"
271278
)
272279

273280

@@ -282,18 +289,13 @@ def commands_to_source(commands: List[str]) -> str:
282289
"""
283290
seen: Set[str] = set()
284291
seen_add = seen.add
285-
regex = re.compile(r"([^.' ]*:[^.' ]*)[. ]")
292+
# regex = re.compile(r"([^.' ]*:[^.' ]*)[. ;]")
286293
source = ""
287294

288295
# filter duplicate lines
289296
for command in commands:
290297
if command not in seen:
291298
seen_add(command)
292-
# extension terms with using `:` must be swapped out
293-
if matches := regex.findall(command):
294-
for match in matches:
295-
command = command.replace(f".{match}", f"['{match}']")
296-
297299
source += command
298300

299301
return source
@@ -308,7 +310,7 @@ def operations_to_script(operations: List) -> Dict:
308310
Returns:
309311
Dict: elasticsearch update script.
310312
"""
311-
commands: List = []
313+
commands: ESCommandSet = ESCommandSet()
312314
for operation in operations:
313315
path = ElasticPath(path=operation.path)
314316
from_path = (
@@ -333,7 +335,7 @@ def operations_to_script(operations: List) -> Dict:
333335
if operation.op == "test":
334336
test_commands(commands=commands, operation=operation, path=path)
335337

336-
source = commands_to_source(commands=commands)
338+
source = "".join(commands)
337339

338340
return {
339341
"source": source,

stac_fastapi/tests/clients/test_elasticsearch.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client):
323323
{"op": "replace", "path": "/properties/proj:epsg", "value": "world"}
324324
),
325325
PatchAddReplaceTest.model_validate(
326-
{"op": "replace", "path": "/properties/area/1", "value": "50"}
326+
{"op": "replace", "path": "/properties/area/1", "value": 50}
327327
),
328328
]
329329

@@ -339,7 +339,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client):
339339
)
340340

341341
assert updated_item["properties"]["gsd"] == 100
342-
assert updated_item["properties"]["proj:epsg"] == 100
342+
assert updated_item["properties"]["proj:epsg"] == "world"
343343
assert updated_item["properties"]["area"] == [2500, 50]
344344

345345

@@ -356,7 +356,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client):
356356
{"op": "test", "path": "/properties/proj:epsg", "value": 32756}
357357
),
358358
PatchAddReplaceTest.model_validate(
359-
{"op": "test", "path": "/properties/area/1", "value": -100}
359+
{"op": "test", "path": "/properties/area/1", "value": -200}
360360
),
361361
]
362362

@@ -373,7 +373,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client):
373373

374374
assert updated_item["properties"]["gsd"] == 15
375375
assert updated_item["properties"]["proj:epsg"] == 32756
376-
assert updated_item["properties"]["area"][1] == -100
376+
assert updated_item["properties"]["area"][1] == -200
377377

378378

379379
@pytest.mark.asyncio
@@ -389,7 +389,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client):
389389
{"op": "move", "path": "/properties/bar", "from": "/properties/proj:epsg"}
390390
),
391391
PatchMoveCopy.model_validate(
392-
{"op": "move", "path": "/properties/hello", "from": "/properties/area/1"}
392+
{"op": "move", "path": "/properties/area/0", "from": "/properties/area/1"}
393393
),
394394
]
395395

@@ -408,8 +408,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client):
408408
assert "gsd" not in updated_item["properties"]
409409
assert updated_item["properties"]["bar"] == 32756
410410
assert "proj:epsg" not in updated_item["properties"]
411-
assert updated_item["properties"]["hello"] == [-100]
412-
assert updated_item["properties"]["area"] == [2500]
411+
assert updated_item["properties"]["area"] == [-200, 2500]
413412

414413

415414
@pytest.mark.asyncio
@@ -425,7 +424,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client):
425424
{"op": "copy", "path": "/properties/bar", "from": "/properties/proj:epsg"}
426425
),
427426
PatchMoveCopy.model_validate(
428-
{"op": "copy", "path": "/properties/hello", "from": "/properties/area/1"}
427+
{"op": "copy", "path": "/properties/area/0", "from": "/properties/area/1"}
429428
),
430429
]
431430

@@ -442,7 +441,9 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client):
442441

443442
assert updated_item["properties"]["foo"] == updated_item["properties"]["gsd"]
444443
assert updated_item["properties"]["bar"] == updated_item["properties"]["proj:epsg"]
445-
assert updated_item["properties"]["hello"] == updated_item["properties"]["area"][1]
444+
assert (
445+
updated_item["properties"]["area"][0] == updated_item["properties"]["area"][1]
446+
)
446447

447448

448449
@pytest.mark.asyncio

0 commit comments

Comments
 (0)