erioon 0.1.4__py3-none-any.whl → 0.1.6__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.
erioon/read.py ADDED
@@ -0,0 +1,301 @@
1
+ # Copyright 2025-present Erioon, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # Visit www.erioon.com/dev-docs for more information about the python SDK
15
+
16
+ import io
17
+ import msgpack
18
+ from azure.storage.blob import ContainerClient
19
+ from erioon.functions import async_log
20
+
21
+ # GET ALL RECORDS OF A COLLECTION
22
+ def handle_get_all(user_id, db_id, coll_id, limit, container_url):
23
+ """
24
+ Retrieves up to a specified number of records from a collection stored in Blob Storage
25
+ and logs the operation status asynchronously.
26
+
27
+ Parameters:
28
+ - user_id (str): Identifier of the user making the request.
29
+ - db_id (str): Database identifier (used as the directory prefix).
30
+ - coll_id (str): Collection identifier (subdirectory under the database).
31
+ - limit (int): Maximum number of records to retrieve (must not exceed 1,000,000).
32
+ - container_url (str): URL to the Blob Storage container.
33
+
34
+ Behavior:
35
+ - Scans all blobs in the specified collection path (`db_id/coll_id/`).
36
+ - Reads shard files, each containing a list of records.
37
+ - Skips duplicate records by checking their `_id`.
38
+ - Stops reading once the record limit is reached.
39
+ - Skips empty or non-conforming blobs gracefully.
40
+
41
+ Returns:
42
+ - tuple(dict, int): A tuple containing:
43
+ - A status dictionary with:
44
+ - "status": "OK" or "KO"
45
+ - "count": number of records returned (0 if none)
46
+ - "results": list of records (only for successful responses)
47
+ - "error": error message (on failure)
48
+ - HTTP status code:
49
+ - 200 if data is successfully returned.
50
+ - 404 if collection is missing or no data found.
51
+ - 500 on unexpected errors.
52
+ """
53
+ if limit > 1_000_000:
54
+ async_log(user_id, db_id, coll_id, "GET", "ERROR", "Limit of 1,000,000 exceeded", 1, container_url)
55
+ return {"status": "KO", "count": 0, "error": "Limit of 1,000,000 exceeded"}, 404
56
+
57
+ directory_path = f"{db_id}/{coll_id}/"
58
+ container_client = ContainerClient.from_container_url(container_url)
59
+
60
+ blob_list = container_client.list_blobs(name_starts_with=directory_path)
61
+ blob_names = [blob.name for blob in blob_list]
62
+
63
+ if not blob_names:
64
+ async_log(user_id, db_id, coll_id, "GET", "ERROR", f"No collection {coll_id} found.", 1, container_url)
65
+ return {"status": "KO", "count": 0, "error": f"No collection {coll_id} found."}, 404
66
+
67
+ results = []
68
+ seen_ids = set()
69
+
70
+ for blob in blob_names:
71
+ try:
72
+ if blob.endswith(".msgpack"):
73
+ blob_client = container_client.get_blob_client(blob)
74
+ msgpack_data = blob_client.download_blob().readall()
75
+
76
+ if not msgpack_data:
77
+ continue
78
+
79
+ with io.BytesIO(msgpack_data) as buffer:
80
+ unpacked_data = msgpack.unpackb(buffer.read(), raw=False)
81
+ if isinstance(unpacked_data, list):
82
+ for record in unpacked_data:
83
+ if record["_id"] in seen_ids:
84
+ continue
85
+
86
+ results.append(record)
87
+ seen_ids.add(record["_id"])
88
+
89
+ if len(results) >= limit:
90
+ async_log(user_id, db_id, coll_id, "GET", "SUCCESS", f"OK", len(results), container_url)
91
+ return {"status": "OK", "count": len(results), "results": results}, 200
92
+
93
+ except Exception:
94
+ continue
95
+
96
+ if results:
97
+ async_log(user_id, db_id, coll_id, "GET", "SUCCESS", f"OK", len(results), container_url)
98
+ return {"status": "OK", "count": len(results), "results": results}, 200
99
+
100
+ async_log(user_id, db_id, coll_id, "GET", "ERROR", "No data found", 1, container_url)
101
+ return {"status": "KO", "count": 0, "error": "No data found"}, 404
102
+
103
+ # FIND ONE RECORD
104
+ def handle_find_one(user_id, db_id, coll_id, search_criteria, container_url):
105
+ """
106
+ Search for a single record matching all given criteria in a collection stored in Blob Storage.
107
+
108
+ The function loads each MsgPack blob under `{db_id}/{coll_id}/` and iterates records to find
109
+ the first one where all key-value criteria match, including nested keys using dot notation.
110
+ It logs the operation result asynchronously.
111
+
112
+ Args:
113
+ user_id (str): ID of the user making the request.
114
+ db_id (str): Identifier for the database.
115
+ coll_id (str): Identifier for the collection.
116
+ search_criteria (list[dict]): List of key-value dicts representing the search filters.
117
+ Nested keys are supported via dot notation (e.g., "address.city").
118
+ container_url (str): Blob Storage container URL.
119
+
120
+ Returns:
121
+ tuple(dict, int): A tuple containing:
122
+ - A dictionary with keys:
123
+ - "status" (str): "OK" if a record is found, "KO" if not.
124
+ - "record" (dict): The found record if successful.
125
+ - "error" (str): Error message if not found.
126
+ - HTTP status code (int): 200 if found, 404 if no matching record or collection.
127
+ """
128
+
129
+ directory_path = f"{db_id}/{coll_id}/"
130
+ container_client = ContainerClient.from_container_url(container_url)
131
+ blob_list = container_client.list_blobs(name_starts_with=directory_path)
132
+ blob_names = [blob.name for blob in blob_list if blob.name.endswith(".msgpack")]
133
+
134
+ if not blob_names:
135
+ async_log(user_id, db_id, coll_id, "GET_ONE", "ERROR", f"No collection {coll_id} found.", 1, container_url)
136
+ return {"status": "KO", "error": f"No collection {coll_id} found."}, 404
137
+
138
+ for blob_name in blob_names:
139
+ try:
140
+ blob_client = container_client.get_blob_client(blob_name)
141
+ msgpack_data = blob_client.download_blob().readall()
142
+ if not msgpack_data:
143
+ continue
144
+
145
+ records = msgpack.unpackb(msgpack_data, raw=False)
146
+
147
+ for record in records:
148
+ matched_all = True
149
+ for criteria in search_criteria:
150
+ key, value = list(criteria.items())[0]
151
+ current = record
152
+ for part in key.split("."):
153
+ if isinstance(current, dict) and part in current:
154
+ current = current[part]
155
+ else:
156
+ matched_all = False
157
+ break
158
+ if current != value:
159
+ matched_all = False
160
+ break
161
+
162
+ if matched_all:
163
+ async_log(user_id, db_id, coll_id, "GET_ONE", "SUCCESS", "Found one record", 1, container_url)
164
+ return {"status": "OK", "record": record}, 200
165
+
166
+ except Exception:
167
+ continue
168
+
169
+ async_log(user_id, db_id, coll_id, "GET_ONE", "ERROR", "No matching record found", 1, container_url)
170
+ return {"status": "KO", "error": "No matching record found"}, 404
171
+
172
+ # FIND MULTIPLE RECORDS
173
+ def handle_find_many(user_id, db_id, coll_id, search_criteria, limit, container_url):
174
+ """
175
+ Search for multiple records matching all given criteria in a collection stored as MsgPack blobs.
176
+
177
+ The function scans all MsgPack blobs under `{db_id}/{coll_id}/` and collects unique records
178
+ that match all provided search criteria, supporting nested keys with dot notation.
179
+ It returns up to `limit` records and logs the operation asynchronously.
180
+
181
+ Args:
182
+ user_id (str): ID of the user making the request.
183
+ db_id (str): Identifier for the database.
184
+ coll_id (str): Identifier for the collection.
185
+ search_criteria (list[dict]): List of key-value dicts for filtering records.
186
+ limit (int): Maximum number of matching records to return.
187
+ container_url (str): Blob Storage container URL.
188
+
189
+ Returns:
190
+ tuple(dict, int): A tuple containing:
191
+ - A dictionary with keys:
192
+ - "status" (str): "OK" if matching records found, "KO" otherwise.
193
+ - "count" (int): Number of records returned.
194
+ - "results" (list[dict]): List of matching records if successful.
195
+ - "error" (str): Error message if none found.
196
+ - HTTP status code (int): 200 if matches found, 404 if none or collection missing.
197
+ """
198
+
199
+ directory_path = f"{db_id}/{coll_id}/"
200
+ container_client = ContainerClient.from_container_url(container_url)
201
+ blob_list = container_client.list_blobs(name_starts_with=directory_path)
202
+ blob_names = [blob.name for blob in blob_list if blob.name.endswith(".msgpack")]
203
+
204
+ if not blob_names:
205
+ async_log(user_id, db_id, coll_id, "GET_MANY", "ERROR", f"No collection {coll_id} found.", 1, container_url)
206
+ return {"status": "KO", "count": 0, "error": f"No collection {coll_id} found."}, 404
207
+
208
+ results = []
209
+ seen_ids = set()
210
+
211
+ for blob_name in blob_names:
212
+ try:
213
+ blob_client = container_client.get_blob_client(blob_name)
214
+ msgpack_data = blob_client.download_blob().readall()
215
+ if not msgpack_data:
216
+ continue
217
+
218
+ records = msgpack.unpackb(msgpack_data, raw=False)
219
+
220
+ for record in records:
221
+ if record.get("_id") in seen_ids:
222
+ continue
223
+
224
+ matched_all = True
225
+ for criteria in search_criteria:
226
+ key, value = list(criteria.items())[0]
227
+ current = record
228
+ for part in key.split("."):
229
+ if isinstance(current, dict) and part in current:
230
+ current = current[part]
231
+ else:
232
+ matched_all = False
233
+ break
234
+ if current != value:
235
+ matched_all = False
236
+ break
237
+
238
+ if matched_all:
239
+ results.append(record)
240
+ seen_ids.add(record.get("_id"))
241
+ if len(results) >= limit:
242
+ async_log(user_id, db_id, coll_id, "GET_MANY", "SUCCESS", "OK", len(results), container_url)
243
+ return {"status": "OK", "count": len(results), "results": results}, 200
244
+
245
+ except Exception:
246
+ continue
247
+
248
+ if results:
249
+ async_log(user_id, db_id, coll_id, "GET_MANY", "SUCCESS", "OK", len(results), container_url)
250
+ return {"status": "OK", "count": len(results), "results": results}, 200
251
+
252
+ async_log(user_id, db_id, coll_id, "GET_MANY", "ERROR", "No matching records found", 1, container_url)
253
+ return {"status": "KO", "count": 0, "error": "No matching records found"}, 404
254
+
255
+ # COUNT ALL THE RECORDS
256
+ def handle_count_records(user_id, db_id, coll_id, container_url):
257
+ """
258
+ Count all records across all MsgPack blobs in a specified collection within Blob Storage.
259
+
260
+ The function aggregates the total number of records found in every `.msgpack` blob under the
261
+ directory `{db_id}/{coll_id}/` and logs the count asynchronously.
262
+
263
+ Args:
264
+ user_id (str): ID of the user making the request.
265
+ db_id (str): Identifier for the database.
266
+ coll_id (str): Identifier for the collection.
267
+ container_url (str): Blob Storage container URL.
268
+
269
+ Returns:
270
+ tuple(int, int): A tuple containing:
271
+ - The total count of records (int).
272
+ - HTTP status code (int): 200 if count obtained successfully, 404 if collection not found.
273
+ """
274
+ directory_path = f"{db_id}/{coll_id}/"
275
+ container_client = ContainerClient.from_container_url(container_url)
276
+ blob_list = container_client.list_blobs(name_starts_with=directory_path)
277
+ blob_names = [blob.name for blob in blob_list if blob.name.endswith(".msgpack")]
278
+
279
+ if not blob_names:
280
+ async_log(user_id, db_id, coll_id, "COUNT", "ERROR", f"No collection {coll_id} found.", 0, container_url)
281
+ return 0, 404
282
+
283
+ total_count = 0
284
+
285
+ for blob_name in blob_names:
286
+ try:
287
+ blob_client = container_client.get_blob_client(blob_name)
288
+ msgpack_data = blob_client.download_blob().readall()
289
+
290
+ if not msgpack_data:
291
+ continue
292
+
293
+ records = msgpack.unpackb(msgpack_data, raw=False)
294
+ if isinstance(records, list):
295
+ total_count += len(records)
296
+
297
+ except Exception:
298
+ continue
299
+
300
+ async_log(user_id, db_id, coll_id, "COUNT", "SUCCESS", f"Total docs: {total_count}", total_count, container_url)
301
+ return total_count, 200
erioon/transaction.py ADDED
@@ -0,0 +1,58 @@
1
+ # Copyright 2025-present Erioon, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # Visit www.erioon.com/dev-docs for more information about the python SDK
15
+
16
+ import uuid
17
+ from erioon.functions import get_index_data, create_msgpack_file, calculate_shard_number, async_log, update_index_file_insert
18
+
19
+ class Transaction:
20
+ def __init__(self, user_id_cont, database, collection, container_url):
21
+ self.user_id_cont = user_id_cont
22
+ self.database = database
23
+ self.collection = collection
24
+ self.container_url = container_url
25
+ self.staged_records = []
26
+
27
+ def insert_one(self, record):
28
+ if "_id" not in record or not record["_id"]:
29
+ record["_id"] = str(uuid.uuid4())
30
+ self.staged_records.append(record)
31
+
32
+ def commit(self):
33
+ # Check duplicates for all staged records
34
+ index_data = get_index_data(self.user_id_cont, self.database, self.collection, self.container_url)
35
+ existing_ids = set()
36
+ for shard in index_data:
37
+ for ids in shard.values():
38
+ existing_ids.update(ids)
39
+
40
+ # Assign new IDs if duplicates found
41
+ for record in self.staged_records:
42
+ if record["_id"] in existing_ids:
43
+ new_id = str(uuid.uuid4())
44
+ record["_id"] = new_id
45
+
46
+ # Write all records to shard files
47
+ for record in self.staged_records:
48
+ create_msgpack_file(self.user_id_cont, self.database, self.collection, record, self.container_url)
49
+ shard_number = calculate_shard_number(self.user_id_cont, self.database, self.collection, self.container_url)
50
+ update_index_file_insert(self.user_id_cont, self.database, self.collection, record["_id"], shard_number, self.container_url)
51
+
52
+ async_log(self.user_id_cont, self.database, self.collection, "POST", "SUCCESS", f"{len(self.staged_records)} records inserted atomically", len(self.staged_records), self.container_url)
53
+
54
+ self.staged_records.clear()
55
+
56
+ def rollback(self):
57
+ self.staged_records.clear()
58
+ async_log(self.user_id_cont, self.database, self.collection, "POST", "ERROR", "Transaction rollback: no records inserted", 0, self.container_url)
erioon/update.py ADDED
@@ -0,0 +1,322 @@
1
+ # Copyright 2025-present Erioon, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # Visit www.erioon.com/dev-docs for more information about the python SDK
15
+
16
+ import msgpack
17
+ from azure.storage.blob import ContainerClient
18
+ from erioon.functions import async_log
19
+
20
+ # UPDATE ONE RECORD
21
+ def handle_update_one(user_id, db_id, coll_id, filter_query, update_query, container_url):
22
+ """
23
+ Updates a single record in a collection stored in Blob Storage based on a filter condition,
24
+ applying one of the supported update operations, and logs the result asynchronously.
25
+
26
+ Supported operations in `update_query`:
27
+ - "$set": Overwrites the value at the specified (possibly nested) key.
28
+ - "$push": Appends a value to a list at the specified key, or initializes the list if it doesn't exist.
29
+ - "$remove": Deletes the specified key from the record.
30
+
31
+ Parameters:
32
+ - user_id (str): Identifier of the user making the update request.
33
+ - db_id (str): Database identifier (used as a directory prefix).
34
+ - coll_id (str): Collection identifier (used as a subdirectory under the database).
35
+ - filter_query (dict): Key-value pairs that must match exactly in the record for it to be updated.
36
+ - update_query (dict): Update operations to apply, using one of the supported operators ($set, $push, $remove).
37
+ - container_url (str): URL of the Blob Storage container where the data is stored.
38
+
39
+ Returns:
40
+ - tuple(dict, int): A tuple containing:
41
+ - A dictionary with either:
42
+ - "success": Confirmation message if update succeeded.
43
+ - "error": Error message if update failed.
44
+ - HTTP status code:
45
+ - 200 if a matching record is updated successfully.
46
+ - 404 if no collections or matching records are found.
47
+ - No 500s are explicitly returned; internal exceptions are silently caught.
48
+ """
49
+ container_client = ContainerClient.from_container_url(container_url)
50
+ directory_path = f"{db_id}/{coll_id}/"
51
+
52
+ blob_list = container_client.list_blobs(name_starts_with=directory_path)
53
+ blob_names = [blob.name for blob in blob_list if blob.name.endswith(".msgpack")]
54
+
55
+ if not blob_names:
56
+ async_log(user_id, db_id, coll_id, "PATCH_UPDT", "ERROR",
57
+ f"No collections found for the database {db_id}", 1, container_url)
58
+ return {"error": f"No collections found for the database {db_id}"}, 404
59
+
60
+ updated = False
61
+
62
+ for blob_name in blob_names:
63
+ try:
64
+ blob_client = container_client.get_blob_client(blob_name)
65
+ msgpack_data = blob_client.download_blob().readall()
66
+
67
+ if not msgpack_data:
68
+ continue
69
+
70
+ data_records = msgpack.unpackb(msgpack_data, raw=False)
71
+ modified_records = []
72
+ local_updated = False
73
+
74
+ for record in data_records:
75
+ if not updated:
76
+ match_found = all(record.get(k) == v for k, v in filter_query.items())
77
+ if match_found:
78
+ for op, changes in update_query.items():
79
+ if op == "$set":
80
+ for key, new_value in changes.items():
81
+ keys = key.split(".")
82
+ nested_obj = record
83
+ for k in keys[:-1]:
84
+ nested_obj = nested_obj.setdefault(k, {})
85
+ nested_obj[keys[-1]] = new_value
86
+
87
+ elif op == "$push":
88
+ for key, new_value in changes.items():
89
+ keys = key.split(".")
90
+ nested_obj = record
91
+ for k in keys[:-1]:
92
+ nested_obj = nested_obj.setdefault(k, {})
93
+ last_key = keys[-1]
94
+ if last_key not in nested_obj:
95
+ nested_obj[last_key] = [new_value]
96
+ elif isinstance(nested_obj[last_key], list):
97
+ nested_obj[last_key].append(new_value)
98
+ else:
99
+ nested_obj[last_key] = [nested_obj[last_key], new_value]
100
+
101
+ elif op == "$remove":
102
+ for key in changes:
103
+ keys = key.split(".")
104
+ nested_obj = record
105
+ for k in keys[:-1]:
106
+ nested_obj = nested_obj.get(k, {})
107
+ last_key = keys[-1]
108
+ if isinstance(nested_obj, dict) and last_key in nested_obj:
109
+ del nested_obj[last_key]
110
+
111
+ updated = True
112
+ local_updated = True
113
+
114
+ modified_records.append(record)
115
+
116
+ if local_updated:
117
+ packed_data = msgpack.packb(modified_records, use_bin_type=True)
118
+ blob_client.upload_blob(packed_data, overwrite=True)
119
+ async_log(user_id, db_id, coll_id, "PATCH_UPDT", "SUCCESS",
120
+ "Record updated successfully", len(modified_records), container_url)
121
+ return {"success": "Record updated successfully"}, 200
122
+
123
+ except Exception:
124
+ continue
125
+
126
+ if not updated:
127
+ async_log(user_id, db_id, coll_id, "PATCH_UPDT", "ERROR",
128
+ "No matching record found", 1, container_url)
129
+ return {"error": "No matching record found"}, 404
130
+
131
+ # UPDATE MULTIPLE RECORDS
132
+ def handle_update_many(user_id, db_id, coll_id, update_tasks, container_url):
133
+ """
134
+ Perform multiple record updates based on a list of filter-and-update tasks.
135
+
136
+ Each task specifies a filter to match records and an update query to apply (using "$set", "$push", "$remove").
137
+ The function applies updates across all blobs within the collection and aggregates results per task.
138
+ For each task, it tracks how many records were updated and logs successes asynchronously.
139
+
140
+ Args:
141
+ user_id (str): The user ID performing the update.
142
+ db_id (str): Database identifier.
143
+ coll_id (str): Collection identifier.
144
+ update_tasks (list[dict]): List of update tasks, each with:
145
+ - "filter": dict specifying key-value pairs to match.
146
+ - "update": dict with update operations.
147
+ container_url (str): Blob Storage container URL.
148
+
149
+ Returns:
150
+ tuple(dict, int): A tuple containing:
151
+ - Response dictionary summarizing:
152
+ - Total number of updated records across all tasks.
153
+ - Detailed results per task (updated count or errors).
154
+ - HTTP status code:
155
+ - 200 if one or more records updated.
156
+ - 404 if no records were updated.
157
+ """
158
+
159
+ container_client = ContainerClient.from_container_url(container_url)
160
+ directory_path = f"{db_id}/{coll_id}/"
161
+ blob_list = list(container_client.list_blobs(name_starts_with=directory_path))
162
+ blob_names = [blob.name for blob in blob_list if blob.name.endswith(".msgpack")]
163
+
164
+ if not blob_names:
165
+ return {"error": f"No collections found under {directory_path}"}, 404
166
+
167
+ total_updates = 0
168
+ task_results = []
169
+
170
+ for task in update_tasks:
171
+ filter_query = task.get("filter", {})
172
+ update_query = task.get("update", {})
173
+ updated_count = 0
174
+
175
+ for blob_name in blob_names:
176
+ try:
177
+ blob_client = container_client.get_blob_client(blob_name)
178
+ msgpack_data = blob_client.download_blob().readall()
179
+
180
+ if not msgpack_data:
181
+ continue
182
+
183
+ records = msgpack.unpackb(msgpack_data, raw=False)
184
+ modified = False
185
+
186
+ for record in records:
187
+ if all(record.get(k) == v for k, v in filter_query.items()):
188
+ for op, changes in update_query.items():
189
+ if op == "$set":
190
+ for key, new_value in changes.items():
191
+ nested = record
192
+ keys = key.split(".")
193
+ for k in keys[:-1]:
194
+ nested = nested.setdefault(k, {})
195
+ nested[keys[-1]] = new_value
196
+
197
+ elif op == "$push":
198
+ for key, value in changes.items():
199
+ nested = record
200
+ keys = key.split(".")
201
+ for k in keys[:-1]:
202
+ nested = nested.setdefault(k, {})
203
+ last_key = keys[-1]
204
+ if last_key not in nested:
205
+ nested[last_key] = [value]
206
+ elif isinstance(nested[last_key], list):
207
+ nested[last_key].append(value)
208
+ else:
209
+ nested[last_key] = [nested[last_key], value]
210
+
211
+ elif op == "$remove":
212
+ for key in changes:
213
+ nested = record
214
+ keys = key.split(".")
215
+ for k in keys[:-1]:
216
+ nested = nested.get(k, {})
217
+ last_key = keys[-1]
218
+ if isinstance(nested, dict) and last_key in nested:
219
+ del nested[last_key]
220
+
221
+ modified = True
222
+ updated_count += 1
223
+
224
+ if modified:
225
+ packed_data = msgpack.packb(records, use_bin_type=True)
226
+ blob_client.upload_blob(packed_data, overwrite=True)
227
+
228
+ except Exception as e:
229
+ task_results.append({
230
+ "filter": filter_query,
231
+ "error": str(e)
232
+ })
233
+ continue
234
+
235
+ if updated_count > 0:
236
+ async_log(user_id, db_id, coll_id, "PATCH_UPDT_MANY", "SUCCESS",
237
+ f"Updated {updated_count} records", updated_count, container_url)
238
+ task_results.append({
239
+ "filter": filter_query,
240
+ "updated": updated_count
241
+ })
242
+ total_updates += updated_count
243
+ else:
244
+ task_results.append({
245
+ "filter": filter_query,
246
+ "updated": 0,
247
+ "error": "No matching records found"
248
+ })
249
+
250
+ if total_updates == 0:
251
+ return {"error": "No records updated", "details": task_results}, 404
252
+ return {"success": f"{total_updates} records updated", "details": task_results}, 200
253
+
254
+ # REPLACE ONE RECORD
255
+ def handle_replace_one(user_id, db_id, coll_id, filter_query, replacement, container_url):
256
+ """
257
+ Replace a single record identified by a single-field filter in a MsgPack collection.
258
+
259
+ This function searches blobs under `{db_id}/{coll_id}/` for a record matching the single filter field.
260
+ Upon finding the record, it replaces the entire record with the provided `replacement` dictionary.
261
+ If `replacement` lacks the `_id` field, the original record’s `_id` is preserved.
262
+ Only one field is allowed in `filter_query`.
263
+
264
+ Args:
265
+ user_id (str): User ID performing the replacement.
266
+ db_id (str): Database identifier.
267
+ coll_id (str): Collection identifier.
268
+ filter_query (dict): Dictionary with exactly one key-value pair to match.
269
+ replacement (dict): New record to replace the matched one.
270
+ container_url (str): Blob Storage container URL.
271
+
272
+ Returns:
273
+ tuple(dict, int): A tuple containing:
274
+ - Response dictionary with:
275
+ - "success": Confirmation message if replacement succeeded.
276
+ - "error": Error message if no matching record was found or filter invalid.
277
+ - HTTP status code:
278
+ - 200 if replacement succeeded.
279
+ - 400 if filter_query is invalid.
280
+ - 404 if no matching record was found.
281
+ """
282
+
283
+ container_client = ContainerClient.from_container_url(container_url)
284
+ directory_path = f"{db_id}/{coll_id}/"
285
+ blob_list = container_client.list_blobs(name_starts_with=directory_path)
286
+ blob_names = [b.name for b in blob_list if b.name.endswith(".msgpack")]
287
+
288
+ if len(filter_query) != 1:
289
+ return {"error": "filter_query must contain exactly one field"}, 400
290
+
291
+ filter_key, filter_value = list(filter_query.items())[0]
292
+
293
+ for blob_name in blob_names:
294
+ try:
295
+ blob_client = container_client.get_blob_client(blob_name)
296
+ blob_data = blob_client.download_blob().readall()
297
+ records = msgpack.unpackb(blob_data, raw=False)
298
+ modified = False
299
+
300
+ for i, record in enumerate(records):
301
+ if record.get(filter_key) == filter_value:
302
+ original_id = record.get("_id")
303
+ new_record = replacement.copy()
304
+ if "_id" not in new_record:
305
+ new_record["_id"] = original_id
306
+ records[i] = new_record
307
+ modified = True
308
+ break
309
+
310
+ if modified:
311
+ packed = msgpack.packb(records, use_bin_type=True)
312
+ blob_client.upload_blob(packed, overwrite=True)
313
+ async_log(user_id, db_id, coll_id, "REPLACE", "SUCCESS",
314
+ "Record replaced successfully", 1, container_url)
315
+ return {"success": "Record replaced successfully"}, 200
316
+
317
+ except Exception as e:
318
+ continue
319
+
320
+ async_log(user_id, db_id, coll_id, "REPLACE", "ERROR",
321
+ "No matching record found", 1, container_url)
322
+ return {"error": "No matching record found"}, 404