sfeos-helpers 6.1.0__py3-none-any.whl → 6.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
2
- Name: sfeos-helpers
3
- Version: 6.1.0
1
+ Metadata-Version: 2.4
2
+ Name: sfeos_helpers
3
+ Version: 6.2.1
4
4
  Summary: Helper library for the Elasticsearch and Opensearch stac-fastapi backends.
5
5
  Home-page: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -15,7 +15,15 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: stac-fastapi.core==6.1.0
18
+ Requires-Dist: stac-fastapi.core==6.2.1
19
+ Dynamic: classifier
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: license
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
19
27
 
20
28
  # stac-fastapi-elasticsearch-opensearch
21
29
 
@@ -104,6 +112,7 @@ This project is built on the following technologies: STAC, stac-fastapi, FastAPI
104
112
  - [Auth](#auth)
105
113
  - [Aggregation](#aggregation)
106
114
  - [Rate Limiting](#rate-limiting)
115
+ - [Datetime-Based Index Management](#datetime-based-index-management)
107
116
 
108
117
  ## Documentation & Resources
109
118
 
@@ -245,10 +254,86 @@ You can customize additional settings in your `.env` file:
245
254
  | `RAISE_ON_BULK_ERROR` | Controls whether bulk insert operations raise exceptions on errors. If set to `true`, the operation will stop and raise an exception when an error occurs. If set to `false`, errors will be logged, and the operation will continue. **Note:** STAC Item and ItemCollection validation errors will always raise, regardless of this flag. | `false` | Optional |
246
255
  | `DATABASE_REFRESH` | Controls whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. | `false` | Optional |
247
256
  | `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. If set to `false`, the POST `/collections` route and related transaction endpoints (including bulk transaction operations) will be unavailable in the API. This is useful for deployments where mutating the catalog via the API should be prevented. | `true` | Optional |
257
+ | `STAC_ITEM_LIMIT` | Sets the environment variable for result limiting to SFEOS for the number of returned items and STAC collections. | `10` | Optional |
248
258
 
249
259
  > [!NOTE]
250
260
  > The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, `ES_VERIFY_CERTS` and `ES_TIMEOUT` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch.
251
261
 
262
+ ## Datetime-Based Index Management
263
+
264
+ ### Overview
265
+
266
+ SFEOS supports two indexing strategies for managing STAC items:
267
+
268
+ 1. **Simple Indexing** (default) - One index per collection
269
+ 2. **Datetime-Based Indexing** - Time-partitioned indexes with automatic management
270
+
271
+ The datetime-based indexing strategy is particularly useful for large temporal datasets. When a user provides a datetime parameter in a query, the system knows exactly which index to search, providing **multiple times faster searches** and significantly **reducing database load**.
272
+
273
+ ### When to Use
274
+
275
+ **Recommended for:**
276
+ - Systems with large collections containing millions of items
277
+ - Systems requiring high-performance temporal searching
278
+
279
+ **Pros:**
280
+ - Multiple times faster queries with datetime filter
281
+ - Reduced database load - only relevant indexes are searched
282
+
283
+ **Cons:**
284
+ - Slightly longer item indexing time (automatic index management)
285
+ - Greater management complexity
286
+
287
+ ### Configuration
288
+
289
+ #### Enabling Datetime-Based Indexing
290
+
291
+ Enable datetime-based indexing by setting the following environment variable:
292
+
293
+ ```bash
294
+ ENABLE_DATETIME_INDEX_FILTERING=true
295
+ ```
296
+
297
+ ### Related Configuration Variables
298
+
299
+ | Variable | Description | Default | Example |
300
+ |----------|-------------|---------|---------|
301
+ | `ENABLE_DATETIME_INDEX_FILTERING` | Enables time-based index partitioning | `false` | `true` |
302
+ | `DATETIME_INDEX_MAX_SIZE_GB` | Maximum size limit for datetime indexes (GB) - note: add +20% to target size due to ES/OS compression | `25` | `50` |
303
+ | `STAC_ITEMS_INDEX_PREFIX` | Prefix for item indexes | `items_` | `stac_items_` |
304
+
305
+ ## How Datetime-Based Indexing Works
306
+
307
+ ### Index and Alias Naming Convention
308
+
309
+ The system uses a precise naming convention:
310
+
311
+ **Physical indexes:**
312
+ ```
313
+ {ITEMS_INDEX_PREFIX}{collection-id}_{uuid4}
314
+ ```
315
+
316
+ **Aliases:**
317
+ ```
318
+ {ITEMS_INDEX_PREFIX}{collection-id} # Main collection alias
319
+ {ITEMS_INDEX_PREFIX}{collection-id}_{start-datetime} # Temporal alias
320
+ {ITEMS_INDEX_PREFIX}{collection-id}_{start-datetime}_{end-datetime} # Closed index alias
321
+ ```
322
+
323
+ **Example:**
324
+
325
+ *Physical indexes:*
326
+ - `items_sentinel-2-l2a_a1b2c3d4-e5f6-7890-abcd-ef1234567890`
327
+
328
+ *Aliases:*
329
+ - `items_sentinel-2-l2a` - main collection alias
330
+ - `items_sentinel-2-l2a_2024-01-01` - active alias from January 1, 2024
331
+ - `items_sentinel-2-l2a_2024-01-01_2024-03-15` - closed index alias (reached size limit)
332
+
333
+ ### Index Size Management
334
+
335
+ **Important - Data Compression:** Elasticsearch and OpenSearch automatically compress data. The configured `DATETIME_INDEX_MAX_SIZE_GB` limit refers to the compressed size on disk. It is recommended to add +20% to the target size to account for compression overhead and metadata.
336
+
252
337
  ## Interacting with the API
253
338
 
254
339
  - **Creating a Collection**:
@@ -557,4 +642,3 @@ You can customize additional settings in your `.env` file:
557
642
  - Ensures fair resource allocation among all clients
558
643
 
559
644
  - **Examples**: Implementation examples are available in the [examples/rate_limit](examples/rate_limit) directory.
560
-
@@ -0,0 +1,32 @@
1
+ stac_fastapi/sfeos_helpers/mappings.py,sha256=z6GJFJUE7bRKF9ODc8_ddkb7JCOokMtj4p2LeaQqrQQ,8237
2
+ stac_fastapi/sfeos_helpers/version.py,sha256=TvDysD5xP1CNnI8XDtkkz5sggBM1uuxjhZCv120q3AU,45
3
+ stac_fastapi/sfeos_helpers/aggregation/__init__.py,sha256=Mym17lFh90by1GnoQgMyIKAqRNJnvCgVSXDYzjBiPQk,1210
4
+ stac_fastapi/sfeos_helpers/aggregation/client.py,sha256=PPUk0kAZnms46FlLGrR5w8wa52vG-dT6BG37896R5CY,17939
5
+ stac_fastapi/sfeos_helpers/aggregation/format.py,sha256=qUW1jjh2EEjy-V7riliFR77grpi-AgsTmP76z60K5Lo,2011
6
+ stac_fastapi/sfeos_helpers/database/__init__.py,sha256=T0YwePfhG3ukL1oUFCh3FYHA9jZZe36FJRYCQplfb18,2645
7
+ stac_fastapi/sfeos_helpers/database/datetime.py,sha256=XMyi9Q09cuP_hj97qbGbHFtelq7WQVPdehUfzqNZFV4,4040
8
+ stac_fastapi/sfeos_helpers/database/document.py,sha256=LtjX15gvaOuZC_k2t_oQhys_c-zRTLN5rwX0hNJkHnM,1725
9
+ stac_fastapi/sfeos_helpers/database/index.py,sha256=g7_sKfd5XUwq4IhdKRNiasejk045dKlullsdeDSZTq8,6585
10
+ stac_fastapi/sfeos_helpers/database/mapping.py,sha256=4-MSd4xH5wg7yoC4aPjzYMDSEvP026bw4k2TfffMT5E,1387
11
+ stac_fastapi/sfeos_helpers/database/query.py,sha256=g2iGdfgqpx6o8GoQJBMl3AMmqcbSf792qvKWfWipR5w,4193
12
+ stac_fastapi/sfeos_helpers/database/utils.py,sha256=sgkvjhWtQVGwQLeEXgPR6VoWpdbuVV-WJIGFUsVxqYg,8715
13
+ stac_fastapi/sfeos_helpers/filter/__init__.py,sha256=n3zL_MhEGOoxMz1KeijyK_UKiZ0MKPl90zHtYI5RAy8,1557
14
+ stac_fastapi/sfeos_helpers/filter/client.py,sha256=QwjYWXkevoVS7HPtoXfeSzDy-_GJnFhPJtJM49D14oU,4229
15
+ stac_fastapi/sfeos_helpers/filter/cql2.py,sha256=Cg9kRYD9CVkVSyRqOyB5oVXmlyteSn2bw88sqklGpUM,955
16
+ stac_fastapi/sfeos_helpers/filter/transform.py,sha256=1GEWQSp-rbq7_1nDVv1ApDbWxt8DswJWxwaxzV85gj4,4644
17
+ stac_fastapi/sfeos_helpers/models/patch.py,sha256=s5n85ktnH6M2SMqpqyItR8uLxliXmnSTg1WO0QLVsmI,3127
18
+ stac_fastapi/sfeos_helpers/search_engine/__init__.py,sha256=Bi0cAtul3FuLjFceTPtEcaWNBfmUX5vKaqDvbSUAm0o,754
19
+ stac_fastapi/sfeos_helpers/search_engine/base.py,sha256=9KOLW3NjW9PzWQzqLuhIjQU7FOHdDnB3ZNwDq469JZU,1400
20
+ stac_fastapi/sfeos_helpers/search_engine/factory.py,sha256=nPty3L8esypSVIzl5IKfmqQ1hVUIjMQ183Ksistr1bM,1066
21
+ stac_fastapi/sfeos_helpers/search_engine/index_operations.py,sha256=mYt1C2HEdwtslNwRHiZkvYTWQSZDwBBnKo5YJXfxnDo,5565
22
+ stac_fastapi/sfeos_helpers/search_engine/inserters.py,sha256=o-I_4OowMJetMwRFPdq8Oix_DAkMNGBw4fYyoa5W6s0,10562
23
+ stac_fastapi/sfeos_helpers/search_engine/managers.py,sha256=nldomKmw8iQfOxeGZbBRGG_rWk-vB5Hy_cOjJ2e0ArE,6454
24
+ stac_fastapi/sfeos_helpers/search_engine/selection/__init__.py,sha256=qKd4KzZkERwF_yhIeFcjAUnq5vQarr3CuXxE3SWmt6c,441
25
+ stac_fastapi/sfeos_helpers/search_engine/selection/base.py,sha256=106c4FK50cgMmTpPJkWdgbExPkU2yIH4Wq684Ww-fYE,859
26
+ stac_fastapi/sfeos_helpers/search_engine/selection/cache_manager.py,sha256=5yrgf9JA4mgRNMPDKih6xySF8mD724lEWnXhWud7m2c,4039
27
+ stac_fastapi/sfeos_helpers/search_engine/selection/factory.py,sha256=vbgNVCUW2lviePqzpgsPLxp6IEqcX3GHiahqN2oVObA,1305
28
+ stac_fastapi/sfeos_helpers/search_engine/selection/selectors.py,sha256=q83nfCfNfLUqtkHpORwNHNRU9Pa-heeaDIPO0RlHb-8,4779
29
+ sfeos_helpers-6.2.1.dist-info/METADATA,sha256=Q_L1-BW_owsnpv1KVGneU7O0y-eP31OA-QlcHHiIpZw,34629
30
+ sfeos_helpers-6.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ sfeos_helpers-6.2.1.dist-info/top_level.txt,sha256=vqn-D9-HsRPTTxy0Vk_KkDmTiMES4owwBQ3ydSZYb2s,13
32
+ sfeos_helpers-6.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.45.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -313,9 +313,11 @@ class EsAsyncBaseAggregationClient(AsyncBaseAggregationClient):
313
313
  )
314
314
 
315
315
  if aggregate_request.datetime:
316
- search = self.database.apply_datetime_filter(
317
- search=search, interval=aggregate_request.datetime
316
+ search, datetime_search = self.database.apply_datetime_filter(
317
+ search=search, datetime=aggregate_request.datetime
318
318
  )
319
+ else:
320
+ datetime_search = {"gte": None, "lte": None}
319
321
 
320
322
  if aggregate_request.bbox:
321
323
  bbox = aggregate_request.bbox
@@ -414,6 +416,7 @@ class EsAsyncBaseAggregationClient(AsyncBaseAggregationClient):
414
416
  geometry_geohash_grid_precision,
415
417
  geometry_geotile_grid_precision,
416
418
  datetime_frequency_interval,
419
+ datetime_search,
417
420
  )
418
421
  except Exception as error:
419
422
  if not isinstance(error, IndexError):
@@ -30,11 +30,12 @@ Function Naming Conventions:
30
30
  """
31
31
 
32
32
  # Re-export all functions for backward compatibility
33
- from .datetime import return_date
33
+ from .datetime import extract_date, extract_first_date_from_index, return_date
34
34
  from .document import mk_actions, mk_item_id
35
35
  from .index import (
36
36
  create_index_templates_shared,
37
37
  delete_item_index_shared,
38
+ filter_indexes_by_datetime,
38
39
  index_alias_by_collection_id,
39
40
  index_by_collection_id,
40
41
  indices,
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "delete_item_index_shared",
54
55
  "index_alias_by_collection_id",
55
56
  "index_by_collection_id",
57
+ "filter_indexes_by_datetime",
56
58
  "indices",
57
59
  # Query operations
58
60
  "apply_free_text_filter_shared",
@@ -68,4 +70,6 @@ __all__ = [
68
70
  "get_bool_env",
69
71
  # Datetime utilities
70
72
  "return_date",
73
+ "extract_date",
74
+ "extract_first_date_from_index",
71
75
  ]
@@ -4,14 +4,19 @@ This module provides datetime utility functions specifically designed for
4
4
  Elasticsearch and OpenSearch query formatting.
5
5
  """
6
6
 
7
+ import logging
8
+ import re
9
+ from datetime import date
7
10
  from datetime import datetime as datetime_type
8
11
  from typing import Dict, Optional, Union
9
12
 
10
13
  from stac_fastapi.types.rfc3339 import DateTimeType
11
14
 
15
+ logger = logging.getLogger(__name__)
16
+
12
17
 
13
18
  def return_date(
14
- interval: Optional[Union[DateTimeType, str]]
19
+ interval: Optional[Union[DateTimeType, str]],
15
20
  ) -> Dict[str, Optional[str]]:
16
21
  """
17
22
  Convert a date interval to an Elasticsearch/OpenSearch query format.
@@ -39,8 +44,14 @@ def return_date(
39
44
  if isinstance(interval, str):
40
45
  if "/" in interval:
41
46
  parts = interval.split("/")
42
- result["gte"] = parts[0] if parts[0] != ".." else None
43
- result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None
47
+ result["gte"] = (
48
+ parts[0] if parts[0] != ".." else datetime_type.min.isoformat() + "Z"
49
+ )
50
+ result["lte"] = (
51
+ parts[1]
52
+ if len(parts) > 1 and parts[1] != ".."
53
+ else datetime_type.max.isoformat() + "Z"
54
+ )
44
55
  else:
45
56
  converted_time = interval if interval != ".." else None
46
57
  result["gte"] = result["lte"] = converted_time
@@ -58,3 +69,53 @@ def return_date(
58
69
  result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
59
70
 
60
71
  return result
72
+
73
+
74
+ def extract_date(date_str: str) -> date:
75
+ """Extract date from ISO format string.
76
+
77
+ Args:
78
+ date_str: ISO format date string
79
+
80
+ Returns:
81
+ A date object extracted from the input string.
82
+ """
83
+ date_str = date_str.replace("Z", "+00:00")
84
+ return datetime_type.fromisoformat(date_str).date()
85
+
86
+
87
+ def extract_first_date_from_index(index_name: str) -> date:
88
+ """Extract the first date from an index name containing date patterns.
89
+
90
+ Searches for date patterns (YYYY-MM-DD) within the index name string
91
+ and returns the first found date as a date object.
92
+
93
+ Args:
94
+ index_name: Index name containing date patterns.
95
+
96
+ Returns:
97
+ A date object extracted from the first date pattern found in the index name.
98
+
99
+ """
100
+ date_pattern = r"\d{4}-\d{2}-\d{2}"
101
+ match = re.search(date_pattern, index_name)
102
+
103
+ if not match:
104
+ logger.error(f"No date pattern found in index name: '{index_name}'")
105
+ raise ValueError(
106
+ f"No date pattern (YYYY-MM-DD) found in index name: '{index_name}'"
107
+ )
108
+
109
+ date_string = match.group(0)
110
+
111
+ try:
112
+ extracted_date = datetime_type.strptime(date_string, "%Y-%m-%d").date()
113
+ return extracted_date
114
+ except ValueError as e:
115
+ logger.error(
116
+ f"Invalid date format found in index name '{index_name}': "
117
+ f"'{date_string}' - {str(e)}"
118
+ )
119
+ raise ValueError(
120
+ f"Invalid date format in index name '{index_name}': '{date_string}'"
121
+ ) from e
@@ -3,9 +3,13 @@
3
3
  This module provides functions for creating and managing indices in Elasticsearch/OpenSearch.
4
4
  """
5
5
 
6
+ import re
7
+ from datetime import datetime
6
8
  from functools import lru_cache
7
9
  from typing import Any, List, Optional
8
10
 
11
+ from dateutil.parser import parse # type: ignore[import]
12
+
9
13
  from stac_fastapi.sfeos_helpers.mappings import (
10
14
  _ES_INDEX_NAME_UNSUPPORTED_CHARS_TABLE,
11
15
  COLLECTIONS_INDEX,
@@ -66,6 +70,59 @@ def indices(collection_ids: Optional[List[str]]) -> str:
66
70
  )
67
71
 
68
72
 
73
+ def filter_indexes_by_datetime(
74
+ indexes: List[str], gte: Optional[str], lte: Optional[str]
75
+ ) -> List[str]:
76
+ """Filter indexes based on datetime range extracted from index names.
77
+
78
+ Args:
79
+ indexes: List of index names containing dates
80
+ gte: Greater than or equal date filter (ISO format, optional 'Z' suffix)
81
+ lte: Less than or equal date filter (ISO format, optional 'Z' suffix)
82
+
83
+ Returns:
84
+ List of filtered index names
85
+ """
86
+
87
+ def parse_datetime(dt_str: str) -> datetime:
88
+ """Parse datetime string, handling both with and without 'Z' suffix."""
89
+ return parse(dt_str).replace(tzinfo=None)
90
+
91
+ def extract_date_range_from_index(index_name: str) -> tuple:
92
+ """Extract start and end dates from index name."""
93
+ date_pattern = r"(\d{4}-\d{2}-\d{2})"
94
+ dates = re.findall(date_pattern, index_name)
95
+
96
+ if len(dates) == 1:
97
+ start_date = datetime.strptime(dates[0], "%Y-%m-%d")
98
+ max_date = datetime.max.replace(microsecond=0)
99
+ return start_date, max_date
100
+ else:
101
+ start_date = datetime.strptime(dates[0], "%Y-%m-%d")
102
+ end_date = datetime.strptime(dates[1], "%Y-%m-%d")
103
+ return start_date, end_date
104
+
105
+ def is_index_in_range(
106
+ start_date: datetime, end_date: datetime, gte_dt: datetime, lte_dt: datetime
107
+ ) -> bool:
108
+ """Check if index date range overlaps with filter range."""
109
+ return not (
110
+ end_date.date() < gte_dt.date() or start_date.date() > lte_dt.date()
111
+ )
112
+
113
+ gte_dt = parse_datetime(gte) if gte else datetime.min.replace(microsecond=0)
114
+ lte_dt = parse_datetime(lte) if lte else datetime.max.replace(microsecond=0)
115
+
116
+ filtered_indexes = []
117
+
118
+ for index in indexes:
119
+ start_date, end_date = extract_date_range_from_index(index)
120
+ if is_index_in_range(start_date, end_date, gte_dt, lte_dt):
121
+ filtered_indexes.append(index)
122
+
123
+ return filtered_indexes
124
+
125
+
69
126
  async def create_index_templates_shared(settings: Any) -> None:
70
127
  """Create index templates for Elasticsearch/OpenSearch Collection and Item indices.
71
128
 
@@ -120,11 +177,11 @@ async def delete_item_index_shared(settings: Any, collection_id: str) -> None:
120
177
  client = settings.create_client
121
178
 
122
179
  name = index_alias_by_collection_id(collection_id)
123
- resolved = await client.indices.resolve_index(name=name)
180
+ resolved = await client.indices.resolve_index(name=name, ignore=[404])
124
181
  if "aliases" in resolved and resolved["aliases"]:
125
182
  [alias] = resolved["aliases"]
126
183
  await client.indices.delete_alias(index=alias["indices"], name=alias["name"])
127
184
  await client.indices.delete(index=alias["indices"])
128
185
  else:
129
- await client.indices.delete(index=name)
186
+ await client.indices.delete(index=name, ignore=[404])
130
187
  await client.close()
@@ -80,11 +80,14 @@ def populate_sort_shared(sortby: List) -> Optional[Dict[str, Dict[str, str]]]:
80
80
  This function transforms a list of sort specifications into the format required by
81
81
  Elasticsearch/OpenSearch for sorting query results. The returned dictionary can be
82
82
  directly used in search requests.
83
+ Always includes 'id' as secondary sort to ensure unique pagination tokens.
83
84
  """
84
85
  if sortby:
85
- return {s.field: {"order": s.direction} for s in sortby}
86
+ sort_config = {s.field: {"order": s.direction} for s in sortby}
87
+ sort_config.setdefault("id", {"order": "asc"})
88
+ return sort_config
86
89
  else:
87
- return None
90
+ return {"id": {"order": "asc"}}
88
91
 
89
92
 
90
93
  def add_collections_to_body(
@@ -76,7 +76,7 @@ def merge_to_operations(data: Dict) -> List:
76
76
  nested_operations = merge_to_operations(value)
77
77
 
78
78
  for nested_operation in nested_operations:
79
- nested_operation.path = f"{key}.{nested_operation.path}"
79
+ nested_operation.path = f"{key}/{nested_operation.path}"
80
80
  operations.append(nested_operation)
81
81
 
82
82
  else:
@@ -90,6 +90,7 @@ def check_commands(
90
90
  op: str,
91
91
  path: ElasticPath,
92
92
  from_path: bool = False,
93
+ create_nest: bool = False,
93
94
  ) -> None:
94
95
  """Add Elasticsearch checks to operation.
95
96
 
@@ -101,25 +102,44 @@ def check_commands(
101
102
 
102
103
  """
103
104
  if path.nest:
104
- commands.add(
105
- f"if (!ctx._source.containsKey('{path.nest}'))"
106
- f"{{Debug.explain('{path.nest} does not exist');}}"
107
- )
108
-
109
- if path.index or op in ["remove", "replace", "test"] or from_path:
110
- commands.add(
111
- f"if (!ctx._source{path.es_nest}.containsKey('{path.key}'))"
112
- f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}"
113
- )
114
-
115
- if from_path and path.index is not None:
116
- commands.add(
117
- f"if ((ctx._source{path.es_location} instanceof ArrayList"
118
- f" && ctx._source{path.es_location}.size() < {path.index})"
119
- f" || (!(ctx._source{path.es_location} instanceof ArrayList)"
120
- f" && !ctx._source{path.es_location}.containsKey('{path.index}')))"
121
- f"{{Debug.explain('{path.path} does not exist');}}"
122
- )
105
+ part_nest = ""
106
+ for index, path_part in enumerate(path.parts):
107
+
108
+ # Create nested dictionaries if not present for merge operations
109
+ if create_nest and not from_path:
110
+ value = "[:]"
111
+ for sub_part in reversed(path.parts[index + 1 :]):
112
+ value = f"['{sub_part}': {value}]"
113
+
114
+ commands.add(
115
+ f"if (!ctx._source{part_nest}.containsKey('{path_part}'))"
116
+ f"{{ctx._source{part_nest}['{path_part}'] = {value};}}"
117
+ f"{'' if index == len(path.parts) - 1 else' else '}"
118
+ )
119
+
120
+ else:
121
+ commands.add(
122
+ f"if (!ctx._source{part_nest}.containsKey('{path_part}'))"
123
+ f"{{Debug.explain('{path_part} in {path.path} does not exist');}}"
124
+ )
125
+
126
+ part_nest += f"['{path_part}']"
127
+
128
+ if from_path or op in ["remove", "replace", "test"]:
129
+
130
+ if isinstance(path.key, int):
131
+ commands.add(
132
+ f"if ((ctx._source{path.es_nest} instanceof ArrayList"
133
+ f" && ctx._source{path.es_nest}.size() < {abs(path.key)})"
134
+ f" || (!(ctx._source{path.es_nest} instanceof ArrayList)"
135
+ f" && !ctx._source{path.es_nest}.containsKey('{path.key}')))"
136
+ f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}"
137
+ )
138
+ else:
139
+ commands.add(
140
+ f"if (!ctx._source{path.es_nest}.containsKey('{path.key}'))"
141
+ f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}"
142
+ )
123
143
 
124
144
 
125
145
  def remove_commands(commands: ESCommandSet, path: ElasticPath) -> None:
@@ -130,15 +150,16 @@ def remove_commands(commands: ESCommandSet, path: ElasticPath) -> None:
130
150
  path (ElasticPath): Path to value to be removed
131
151
 
132
152
  """
133
- if path.index is not None:
153
+ commands.add(f"def {path.variable_name};")
154
+ if isinstance(path.key, int):
134
155
  commands.add(
135
- f"def {path.variable_name} = ctx._source{path.es_location}.remove({path.index});"
156
+ f"if (ctx._source{path.es_nest} instanceof ArrayList)"
157
+ f"{{{path.variable_name} = ctx._source{path.es_nest}.remove({path.es_key});}} else "
136
158
  )
137
159
 
138
- else:
139
- commands.add(
140
- f"def {path.variable_name} = ctx._source{path.es_nest}.remove('{path.key}');"
141
- )
160
+ commands.add(
161
+ f"{path.variable_name} = ctx._source{path.es_nest}.remove('{path.key}');"
162
+ )
142
163
 
143
164
 
144
165
  def add_commands(
@@ -160,21 +181,22 @@ def add_commands(
160
181
  value = (
161
182
  from_path.variable_name
162
183
  if operation.op == "move"
163
- else f"ctx._source.{from_path.es_path}"
184
+ else f"ctx._source{from_path.es_path}"
164
185
  )
186
+
165
187
  else:
166
188
  value = f"params.{path.param_key}"
167
189
  params[path.param_key] = operation.value
168
190
 
169
- if path.index is not None:
191
+ if isinstance(path.key, int):
170
192
  commands.add(
171
- f"if (ctx._source{path.es_location} instanceof ArrayList)"
172
- f"{{ctx._source{path.es_location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}"
173
- f"else{{ctx._source.{path.es_path} = {value}}}"
193
+ f"if (ctx._source{path.es_nest} instanceof ArrayList)"
194
+ f"{{ctx._source{path.es_nest}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.es_key}, {value});}}"
195
+ f" else ctx._source{path.es_nest}['{path.es_key}'] = {value};"
174
196
  )
175
197
 
176
198
  else:
177
- commands.add(f"ctx._source.{path.es_path} = {value};")
199
+ commands.add(f"ctx._source{path.es_path} = {value};")
178
200
 
179
201
 
180
202
  def test_commands(
@@ -190,14 +212,23 @@ def test_commands(
190
212
  value = f"params.{path.param_key}"
191
213
  params[path.param_key] = operation.value
192
214
 
215
+ if isinstance(path.key, int):
216
+ commands.add(
217
+ f"if (ctx._source{path.es_nest} instanceof ArrayList)"
218
+ f"{{if (ctx._source{path.es_nest}[{path.es_key}] != {value})"
219
+ f"{{Debug.explain('Test failed `{path.path}`"
220
+ f" != ' + ctx._source{path.es_path});}}"
221
+ f"}} else "
222
+ )
223
+
193
224
  commands.add(
194
- f"if (ctx._source.{path.es_path} != {value})"
195
- f"{{Debug.explain('Test failed `{path.path}` | "
196
- f"{operation.json_value} != ' + ctx._source.{path.es_path});}}"
225
+ f"if (ctx._source{path.es_path} != {value})"
226
+ f"{{Debug.explain('Test failed `{path.path}`"
227
+ f" != ' + ctx._source{path.es_path});}}"
197
228
  )
198
229
 
199
230
 
200
- def operations_to_script(operations: List) -> Dict:
231
+ def operations_to_script(operations: List, create_nest: bool = False) -> Dict:
201
232
  """Convert list of operation to painless script.
202
233
 
203
234
  Args:
@@ -215,10 +246,16 @@ def operations_to_script(operations: List) -> Dict:
215
246
  ElasticPath(path=operation.from_) if hasattr(operation, "from_") else None
216
247
  )
217
248
 
218
- check_commands(commands=commands, op=operation.op, path=path)
249
+ check_commands(
250
+ commands=commands, op=operation.op, path=path, create_nest=create_nest
251
+ )
219
252
  if from_path is not None:
220
253
  check_commands(
221
- commands=commands, op=operation.op, path=from_path, from_path=True
254
+ commands=commands,
255
+ op=operation.op,
256
+ path=from_path,
257
+ from_path=True,
258
+ create_nest=create_nest,
222
259
  )
223
260
 
224
261
  if operation.op in ["remove", "move"]: