Code Coverage

Cobertura Coverage Report > osm_common >

dbmemory.py

Trend

File Coverage summary

NameClassesLinesConditionals
dbmemory.py
100%
1/1
36%
124/341
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
dbmemory.py
36%
124/341
N/A

Source

osm_common/dbmemory.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Telefonica S.A.
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 #    http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14 # implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
17
18 1 from copy import deepcopy
19 1 from http import HTTPStatus
20 1 import logging
21 1 from uuid import uuid4
22
23 1 from osm_common.dbbase import DbBase, DbException
24 1 from osm_common.dbmongo import deep_update
25
26
27 1 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
28
29
30 1 class DbMemory(DbBase):
31 1     def __init__(self, logger_name="db", lock=False):
32 1         super().__init__(logger_name=logger_name, lock=lock)
33 1         self.db = {}
34
35 1     def db_connect(self, config):
36         """
37         Connect to database
38         :param config: Configuration of database
39         :return: None or raises DbException on error
40         """
41 1         if "logger_name" in config:
42 1             self.logger = logging.getLogger(config["logger_name"])
43 1         master_key = config.get("commonkey") or config.get("masterpassword")
44 1         if master_key:
45 0             self.set_secret_key(master_key)
46
47 1     @staticmethod
48 1     def _format_filter(q_filter):
49 1         db_filter = {}
50         # split keys with ANYINDEX in this way:
51         # {"A.B.ANYINDEX.C.D.ANYINDEX.E": v }  -> {"A.B.ANYINDEX": {"C.D.ANYINDEX": {"E": v}}}
52 1         if q_filter:
53 0             for k, v in q_filter.items():
54 0                 db_v = v
55 0                 kleft, _, kright = k.rpartition(".ANYINDEX.")
56 0                 while kleft:
57 0                     k = kleft + ".ANYINDEX"
58 0                     db_v = {kright: db_v}
59 0                     kleft, _, kright = k.rpartition(".ANYINDEX.")
60 0                 deep_update(db_filter, {k: db_v})
61
62 1         return db_filter
63
64 1     def _find(self, table, q_filter):
65 0         def recursive_find(key_list, key_next_index, content, oper, target):
66 0             if key_next_index == len(key_list) or content is None:
67 0                 try:
68 0                     if oper in ("eq", "cont"):
69 0                         if isinstance(target, list):
70 0                             if isinstance(content, list):
71 0                                 return any(
72                                     content_item in target for content_item in content
73                                 )
74 0                             return content in target
75 0                         elif isinstance(content, list):
76 0                             return target in content
77                         else:
78 0                             return content == target
79 0                     elif oper in ("neq", "ne", "ncont"):
80 0                         if isinstance(target, list):
81 0                             if isinstance(content, list):
82 0                                 return all(
83                                     content_item not in target
84                                     for content_item in content
85                                 )
86 0                             return content not in target
87 0                         elif isinstance(content, list):
88 0                             return target not in content
89                         else:
90 0                             return content != target
91 0                     if oper == "gt":
92 0                         return content > target
93 0                     elif oper == "gte":
94 0                         return content >= target
95 0                     elif oper == "lt":
96 0                         return content < target
97 0                     elif oper == "lte":
98 0                         return content <= target
99                     else:
100 0                         raise DbException(
101                             "Unknown filter operator '{}' in key '{}'".format(
102                                 oper, ".".join(key_list)
103                             ),
104                             http_code=HTTPStatus.BAD_REQUEST,
105                         )
106 0                 except TypeError:
107 0                     return False
108
109 0             elif isinstance(content, dict):
110 0                 return recursive_find(
111                     key_list,
112                     key_next_index + 1,
113                     content.get(key_list[key_next_index]),
114                     oper,
115                     target,
116                 )
117 0             elif isinstance(content, list):
118 0                 look_for_match = True  # when there is a match return immediately
119 0                 if (target is None) != (
120                     oper in ("neq", "ne", "ncont")
121                 ):  # one True and other False (Xor)
122 0                     look_for_match = (
123                         False  # when there is not a match return immediately
124                     )
125
126 0                 for content_item in content:
127 0                     if key_list[key_next_index] == "ANYINDEX" and isinstance(v, dict):
128 0                         matches = True
129 0                         if target:
130 0                             for k2, v2 in target.items():
131 0                                 k_new_list = k2.split(".")
132 0                                 new_operator = "eq"
133 0                                 if k_new_list[-1] in (
134                                     "eq",
135                                     "ne",
136                                     "gt",
137                                     "gte",
138                                     "lt",
139                                     "lte",
140                                     "cont",
141                                     "ncont",
142                                     "neq",
143                                 ):
144 0                                     new_operator = k_new_list.pop()
145 0                                 if not recursive_find(
146                                     k_new_list, 0, content_item, new_operator, v2
147                                 ):
148 0                                     matches = False
149 0                                     break
150
151                     else:
152 0                         matches = recursive_find(
153                             key_list, key_next_index, content_item, oper, target
154                         )
155 0                     if matches == look_for_match:
156 0                         return matches
157 0                 if key_list[key_next_index].isdecimal() and int(
158                     key_list[key_next_index]
159                 ) < len(content):
160 0                     matches = recursive_find(
161                         key_list,
162                         key_next_index + 1,
163                         content[int(key_list[key_next_index])],
164                         oper,
165                         target,
166                     )
167 0                     if matches == look_for_match:
168 0                         return matches
169 0                 return not look_for_match
170             else:  # content is not dict, nor list neither None, so not found
171 0                 if oper in ("neq", "ne", "ncont"):
172 0                     return target is not None
173                 else:
174 0                     return target is None
175
176 0         for i, row in enumerate(self.db.get(table, ())):
177 0             q_filter = q_filter or {}
178 0             for k, v in q_filter.items():
179 0                 k_list = k.split(".")
180 0                 operator = "eq"
181 0                 if k_list[-1] in (
182                     "eq",
183                     "ne",
184                     "gt",
185                     "gte",
186                     "lt",
187                     "lte",
188                     "cont",
189                     "ncont",
190                     "neq",
191                 ):
192 0                     operator = k_list.pop()
193 0                 matches = recursive_find(k_list, 0, row, operator, v)
194 0                 if not matches:
195 0                     break
196             else:
197                 # match
198 0                 yield i, row
199
200 1     def get_list(self, table, q_filter=None):
201         """
202         Obtain a list of entries matching q_filter
203         :param table: collection or table
204         :param q_filter: Filter
205         :return: a list (can be empty) with the found entries. Raises DbException on error
206         """
207 0         try:
208 0             result = []
209 0             with self.lock:
210 0                 for _, row in self._find(table, self._format_filter(q_filter)):
211 0                     result.append(deepcopy(row))
212 0             return result
213 0         except DbException:
214 0             raise
215 0         except Exception as e:  # TODO refine
216 0             raise DbException(str(e))
217
218 1     def count(self, table, q_filter=None):
219         """
220         Count the number of entries matching q_filter
221         :param table: collection or table
222         :param q_filter: Filter
223         :return: number of entries found (can be zero)
224         :raise: DbException on error
225         """
226 0         try:
227 0             with self.lock:
228 0                 return sum(1 for x in self._find(table, self._format_filter(q_filter)))
229 0         except DbException:
230 0             raise
231 0         except Exception as e:  # TODO refine
232 0             raise DbException(str(e))
233
234 1     def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
235         """
236         Obtain one entry matching q_filter
237         :param table: collection or table
238         :param q_filter: Filter
239         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
240         it raises a DbException
241         :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
242         that it raises a DbException
243         :return: The requested element, or None
244         """
245 0         try:
246 0             result = None
247 0             with self.lock:
248 0                 for _, row in self._find(table, self._format_filter(q_filter)):
249 0                     if not fail_on_more:
250 0                         return deepcopy(row)
251 0                     if result:
252 0                         raise DbException(
253                             "Found more than one entry with filter='{}'".format(
254                                 q_filter
255                             ),
256                             HTTPStatus.CONFLICT.value,
257                         )
258 0                     result = row
259 0             if not result and fail_on_empty:
260 0                 raise DbException(
261                     "Not found entry with filter='{}'".format(q_filter),
262                     HTTPStatus.NOT_FOUND,
263                 )
264 0             return deepcopy(result)
265 0         except Exception as e:  # TODO refine
266 0             raise DbException(str(e))
267
268 1     def del_list(self, table, q_filter=None):
269         """
270         Deletes all entries that match q_filter
271         :param table: collection or table
272         :param q_filter: Filter
273         :return: Dict with the number of entries deleted
274         """
275 0         try:
276 0             id_list = []
277 0             with self.lock:
278 0                 for i, _ in self._find(table, self._format_filter(q_filter)):
279 0                     id_list.append(i)
280 0             deleted = len(id_list)
281 0             for i in reversed(id_list):
282 0                 del self.db[table][i]
283 0             return {"deleted": deleted}
284 0         except DbException:
285 0             raise
286 0         except Exception as e:  # TODO refine
287 0             raise DbException(str(e))
288
289 1     def del_one(self, table, q_filter=None, fail_on_empty=True):
290         """
291         Deletes one entry that matches q_filter
292         :param table: collection or table
293         :param q_filter: Filter
294         :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
295         which case it raises a DbException
296         :return: Dict with the number of entries deleted
297         """
298 0         try:
299 0             with self.lock:
300 0                 for i, _ in self._find(table, self._format_filter(q_filter)):
301 0                     break
302                 else:
303 0                     if fail_on_empty:
304 0                         raise DbException(
305                             "Not found entry with filter='{}'".format(q_filter),
306                             HTTPStatus.NOT_FOUND,
307                         )
308 0                     return None
309 0                 del self.db[table][i]
310 0             return {"deleted": 1}
311 0         except Exception as e:  # TODO refine
312 0             raise DbException(str(e))
313
314 1     def _update(
315         self,
316         db_item,
317         update_dict,
318         unset=None,
319         pull=None,
320         push=None,
321         push_list=None,
322         pull_list=None,
323     ):
324         """
325         Modifies an entry at database
326         :param db_item: entry of the table to update
327         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
328         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
329                       ignored. If not exist, it is ignored
330         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
331                      if exist in the array is removed. If not exist, it is ignored
332         :param pull_list: Same as pull but values are arrays where each item is removed from the array
333         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
334                      is appended to the end of the array
335         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
336                           whole array
337         :return: True if database has been changed, False if not; Exception on error
338         """
339
340 1         def _iterate_keys(k, db_nested, populate=True):
341 1             k_list = k.split(".")
342 1             k_item_prev = k_list[0]
343 1             populated = False
344 1             if k_item_prev not in db_nested and populate:
345 1                 populated = True
346 1                 db_nested[k_item_prev] = None
347 1             for k_item in k_list[1:]:
348 1                 if isinstance(db_nested[k_item_prev], dict):
349 1                     if k_item not in db_nested[k_item_prev]:
350 1                         if not populate:
351 1                             raise DbException(
352                                 "Cannot set '{}', not existing '{}'".format(k, k_item)
353                             )
354 1                         populated = True
355 1                         db_nested[k_item_prev][k_item] = None
356 1                 elif isinstance(db_nested[k_item_prev], list) and k_item.isdigit():
357                     # extend list with Nones if index greater than list
358 1                     k_item = int(k_item)
359 1                     if k_item >= len(db_nested[k_item_prev]):
360 1                         if not populate:
361 1                             raise DbException(
362                                 "Cannot set '{}', index too large '{}'".format(
363                                     k, k_item
364                                 )
365                             )
366 1                         populated = True
367 1                         db_nested[k_item_prev] += [None] * (
368                             k_item - len(db_nested[k_item_prev]) + 1
369                         )
370 1                 elif db_nested[k_item_prev] is None:
371 1                     if not populate:
372 0                         raise DbException(
373                             "Cannot set '{}', not existing '{}'".format(k, k_item)
374                         )
375 1                     populated = True
376 1                     db_nested[k_item_prev] = {k_item: None}
377                 else:  # number, string, boolean, ... or list but with not integer key
378 1                     raise DbException(
379                         "Cannot set '{}' on existing '{}={}'".format(
380                             k, k_item_prev, db_nested[k_item_prev]
381                         )
382                     )
383 1                 db_nested = db_nested[k_item_prev]
384 1                 k_item_prev = k_item
385 1             return db_nested, k_item_prev, populated
386
387 1         updated = False
388 1         try:
389 1             if update_dict:
390 1                 for dot_k, v in update_dict.items():
391 1                     dict_to_update, key_to_update, _ = _iterate_keys(dot_k, db_item)
392 1                     dict_to_update[key_to_update] = v
393 1                     updated = True
394 1             if unset:
395 1                 for dot_k in unset:
396 1                     try:
397 1                         dict_to_update, key_to_update, _ = _iterate_keys(
398                             dot_k, db_item, populate=False
399                         )
400 1                         del dict_to_update[key_to_update]
401 1                         updated = True
402 1                     except Exception as unset_error:
403 1                         self.logger.error(f"{unset_error} occured while updating DB.")
404 1             if pull:
405 1                 for dot_k, v in pull.items():
406 1                     try:
407 1                         dict_to_update, key_to_update, _ = _iterate_keys(
408                             dot_k, db_item, populate=False
409                         )
410 1                     except Exception as pull_error:
411 1                         self.logger.error(f"{pull_error} occured while updating DB.")
412 1                         continue
413
414 1                     if key_to_update not in dict_to_update:
415 1                         continue
416 1                     if not isinstance(dict_to_update[key_to_update], list):
417 1                         raise DbException(
418                             "Cannot pull '{}'. Target is not a list".format(dot_k)
419                         )
420 1                     while v in dict_to_update[key_to_update]:
421 1                         dict_to_update[key_to_update].remove(v)
422 1                         updated = True
423 1             if pull_list:
424 0                 for dot_k, v in pull_list.items():
425 0                     if not isinstance(v, list):
426 0                         raise DbException(
427                             "Invalid content at pull_list, '{}' must be an array".format(
428                                 dot_k
429                             ),
430                             http_code=HTTPStatus.BAD_REQUEST,
431                         )
432 0                     try:
433 0                         dict_to_update, key_to_update, _ = _iterate_keys(
434                             dot_k, db_item, populate=False
435                         )
436 0                     except Exception as iterate_error:
437 0                         self.logger.error(
438                             f"{iterate_error} occured while iterating keys in db update."
439                         )
440 0                         continue
441
442 0                     if key_to_update not in dict_to_update:
443 0                         continue
444 0                     if not isinstance(dict_to_update[key_to_update], list):
445 0                         raise DbException(
446                             "Cannot pull_list '{}'. Target is not a list".format(dot_k)
447                         )
448 0                     for single_v in v:
449 0                         while single_v in dict_to_update[key_to_update]:
450 0                             dict_to_update[key_to_update].remove(single_v)
451 0                             updated = True
452 1             if push:
453 1                 for dot_k, v in push.items():
454 1                     dict_to_update, key_to_update, populated = _iterate_keys(
455                         dot_k, db_item
456                     )
457 1                     if (
458                         isinstance(dict_to_update, dict)
459                         and key_to_update not in dict_to_update
460                     ):
461 0                         dict_to_update[key_to_update] = [v]
462 0                         updated = True
463 1                     elif populated and dict_to_update[key_to_update] is None:
464 1                         dict_to_update[key_to_update] = [v]
465 1                         updated = True
466 1                     elif not isinstance(dict_to_update[key_to_update], list):
467 1                         raise DbException(
468                             "Cannot push '{}'. Target is not a list".format(dot_k)
469                         )
470                     else:
471 1                         dict_to_update[key_to_update].append(v)
472 1                         updated = True
473 1             if push_list:
474 1                 for dot_k, v in push_list.items():
475 1                     if not isinstance(v, list):
476 1                         raise DbException(
477                             "Invalid content at push_list, '{}' must be an array".format(
478                                 dot_k
479                             ),
480                             http_code=HTTPStatus.BAD_REQUEST,
481                         )
482 1                     dict_to_update, key_to_update, populated = _iterate_keys(
483                         dot_k, db_item
484                     )
485 1                     if (
486                         isinstance(dict_to_update, dict)
487                         and key_to_update not in dict_to_update
488                     ):
489 0                         dict_to_update[key_to_update] = v.copy()
490 0                         updated = True
491 1                     elif populated and dict_to_update[key_to_update] is None:
492 1                         dict_to_update[key_to_update] = v.copy()
493 1                         updated = True
494 1                     elif not isinstance(dict_to_update[key_to_update], list):
495 1                         raise DbException(
496                             "Cannot push '{}'. Target is not a list".format(dot_k),
497                             http_code=HTTPStatus.CONFLICT,
498                         )
499                     else:
500 1                         dict_to_update[key_to_update] += v
501 1                         updated = True
502
503 1             return updated
504 1         except DbException:
505 1             raise
506 0         except Exception as e:  # TODO refine
507 0             raise DbException(str(e))
508
509 1     def set_one(
510         self,
511         table,
512         q_filter,
513         update_dict,
514         fail_on_empty=True,
515         unset=None,
516         pull=None,
517         push=None,
518         push_list=None,
519         pull_list=None,
520     ):
521         """
522         Modifies an entry at database
523         :param table: collection or table
524         :param q_filter: Filter
525         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
526         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
527         it raises a DbException
528         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
529                       ignored. If not exist, it is ignored
530         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
531                      if exist in the array is removed. If not exist, it is ignored
532         :param pull_list: Same as pull but values are arrays where each item is removed from the array
533         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
534                      is appended to the end of the array
535         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
536                           whole array
537         :return: Dict with the number of entries modified. None if no matching is found.
538         """
539 1         with self.lock:
540 1             for i, db_item in self._find(table, self._format_filter(q_filter)):
541 1                 updated = self._update(
542                     db_item,
543                     update_dict,
544                     unset=unset,
545                     pull=pull,
546                     push=push,
547                     push_list=push_list,
548                     pull_list=pull_list,
549                 )
550 1                 return {"updated": 1 if updated else 0}
551             else:
552 0                 if fail_on_empty:
553 0                     raise DbException(
554                         "Not found entry with _id='{}'".format(q_filter),
555                         HTTPStatus.NOT_FOUND,
556                     )
557 0                 return None
558
559 1     def set_list(
560         self,
561         table,
562         q_filter,
563         update_dict,
564         unset=None,
565         pull=None,
566         push=None,
567         push_list=None,
568         pull_list=None,
569     ):
570         """Modifies al matching entries at database. Same as push. Do not fail if nothing matches"""
571 0         with self.lock:
572 0             updated = 0
573 0             found = 0
574 0             for _, db_item in self._find(table, self._format_filter(q_filter)):
575 0                 found += 1
576 0                 if self._update(
577                     db_item,
578                     update_dict,
579                     unset=unset,
580                     pull=pull,
581                     push=push,
582                     push_list=push_list,
583                     pull_list=pull_list,
584                 ):
585 0                     updated += 1
586             # if not found and fail_on_empty:
587             #     raise DbException("Not found entry with '{}'".format(q_filter), HTTPStatus.NOT_FOUND)
588 0             return {"updated": updated} if found else None
589
590 1     def replace(self, table, _id, indata, fail_on_empty=True):
591         """
592         Replace the content of an entry
593         :param table: collection or table
594         :param _id: internal database id
595         :param indata: content to replace
596         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
597         it raises a DbException
598         :return: Dict with the number of entries replaced
599         """
600 0         try:
601 0             with self.lock:
602 0                 for i, _ in self._find(table, self._format_filter({"_id": _id})):
603 0                     break
604                 else:
605 0                     if fail_on_empty:
606 0                         raise DbException(
607                             "Not found entry with _id='{}'".format(_id),
608                             HTTPStatus.NOT_FOUND,
609                         )
610 0                     return None
611 0                 self.db[table][i] = deepcopy(indata)
612 0             return {"updated": 1}
613 0         except DbException:
614 0             raise
615 0         except Exception as e:  # TODO refine
616 0             raise DbException(str(e))
617
618 1     def create(self, table, indata):
619         """
620         Add a new entry at database
621         :param table: collection or table
622         :param indata: content to be added
623         :return: database '_id' of the inserted element. Raises a DbException on error
624         """
625 0         try:
626 0             id = indata.get("_id")
627 0             if not id:
628 0                 id = str(uuid4())
629 0                 indata["_id"] = id
630 0             with self.lock:
631 0                 if table not in self.db:
632 0                     self.db[table] = []
633 0                 self.db[table].append(deepcopy(indata))
634 0             return id
635 0         except Exception as e:  # TODO refine
636 0             raise DbException(str(e))
637
638 1     def create_list(self, table, indata_list):
639         """
640         Add a new entry at database
641         :param table: collection or table
642         :param indata_list: list content to be added
643         :return: list of inserted 'id's. Raises a DbException on error
644         """
645 0         try:
646 0             _ids = []
647 0             with self.lock:
648 0                 for indata in indata_list:
649 0                     _id = indata.get("_id")
650 0                     if not _id:
651 0                         _id = str(uuid4())
652 0                         indata["_id"] = _id
653 0                     with self.lock:
654 0                         if table not in self.db:
655 0                             self.db[table] = []
656 0                         self.db[table].append(deepcopy(indata))
657 0                     _ids.append(_id)
658 0             return _ids
659 0         except Exception as e:  # TODO refine
660 0             raise DbException(str(e))
661
662
663 1 if __name__ == "__main__":
664     # some test code
665 0     db = DbMemory()
666 0     db.create("test", {"_id": 1, "data": 1})
667 0     db.create("test", {"_id": 2, "data": 2})
668 0     db.create("test", {"_id": 3, "data": 3})
669 0     print("must be 3 items:", db.get_list("test"))
670 0     print("must return item 2:", db.get_list("test", {"_id": 2}))
671 0     db.del_one("test", {"_id": 2})
672 0     print("must be emtpy:", db.get_list("test", {"_id": 2}))