Code Coverage

Cobertura Coverage Report > osm_common >

dbmemory.py

Trend

Classes100%
 
Lines36%
   
Conditionals100%
 

File Coverage summary

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

Coverage Breakdown by Class

NameLinesConditionals
dbmemory.py
36%
124/340
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, 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                         for k2, v2 in target.items():
130 0                             k_new_list = k2.split(".")
131 0                             new_operator = "eq"
132 0                             if k_new_list[-1] in (
133                                 "eq",
134                                 "ne",
135                                 "gt",
136                                 "gte",
137                                 "lt",
138                                 "lte",
139                                 "cont",
140                                 "ncont",
141                                 "neq",
142                             ):
143 0                                 new_operator = k_new_list.pop()
144 0                             if not recursive_find(
145                                 k_new_list, 0, content_item, new_operator, v2
146                             ):
147 0                                 matches = False
148 0                                 break
149
150                     else:
151 0                         matches = recursive_find(
152                             key_list, key_next_index, content_item, oper, target
153                         )
154 0                     if matches == look_for_match:
155 0                         return matches
156 0                 if key_list[key_next_index].isdecimal() and int(
157                     key_list[key_next_index]
158                 ) < len(content):
159 0                     matches = recursive_find(
160                         key_list,
161                         key_next_index + 1,
162                         content[int(key_list[key_next_index])],
163                         oper,
164                         target,
165                     )
166 0                     if matches == look_for_match:
167 0                         return matches
168 0                 return not look_for_match
169             else:  # content is not dict, nor list neither None, so not found
170 0                 if oper in ("neq", "ne", "ncont"):
171 0                     return target is not None
172                 else:
173 0                     return target is None
174
175 0         for i, row in enumerate(self.db.get(table, ())):
176 0             q_filter = q_filter or {}
177 0             for k, v in q_filter.items():
178 0                 k_list = k.split(".")
179 0                 operator = "eq"
180 0                 if k_list[-1] in (
181                     "eq",
182                     "ne",
183                     "gt",
184                     "gte",
185                     "lt",
186                     "lte",
187                     "cont",
188                     "ncont",
189                     "neq",
190                 ):
191 0                     operator = k_list.pop()
192 0                 matches = recursive_find(k_list, 0, row, operator, v)
193 0                 if not matches:
194 0                     break
195             else:
196                 # match
197 0                 yield i, row
198
199 1     def get_list(self, table, q_filter=None):
200         """
201         Obtain a list of entries matching q_filter
202         :param table: collection or table
203         :param q_filter: Filter
204         :return: a list (can be empty) with the found entries. Raises DbException on error
205         """
206 0         try:
207 0             result = []
208 0             with self.lock:
209 0                 for _, row in self._find(table, self._format_filter(q_filter)):
210 0                     result.append(deepcopy(row))
211 0             return result
212 0         except DbException:
213 0             raise
214 0         except Exception as e:  # TODO refine
215 0             raise DbException(str(e))
216
217 1     def count(self, table, q_filter=None):
218         """
219         Count the number of entries matching q_filter
220         :param table: collection or table
221         :param q_filter: Filter
222         :return: number of entries found (can be zero)
223         :raise: DbException on error
224         """
225 0         try:
226 0             with self.lock:
227 0                 return sum(1 for x in self._find(table, self._format_filter(q_filter)))
228 0         except DbException:
229 0             raise
230 0         except Exception as e:  # TODO refine
231 0             raise DbException(str(e))
232
233 1     def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
234         """
235         Obtain one entry matching q_filter
236         :param table: collection or table
237         :param q_filter: Filter
238         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
239         it raises a DbException
240         :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
241         that it raises a DbException
242         :return: The requested element, or None
243         """
244 0         try:
245 0             result = None
246 0             with self.lock:
247 0                 for _, row in self._find(table, self._format_filter(q_filter)):
248 0                     if not fail_on_more:
249 0                         return deepcopy(row)
250 0                     if result:
251 0                         raise DbException(
252                             "Found more than one entry with filter='{}'".format(
253                                 q_filter
254                             ),
255                             HTTPStatus.CONFLICT.value,
256                         )
257 0                     result = row
258 0             if not result and fail_on_empty:
259 0                 raise DbException(
260                     "Not found entry with filter='{}'".format(q_filter),
261                     HTTPStatus.NOT_FOUND,
262                 )
263 0             return deepcopy(result)
264 0         except Exception as e:  # TODO refine
265 0             raise DbException(str(e))
266
267 1     def del_list(self, table, q_filter=None):
268         """
269         Deletes all entries that match q_filter
270         :param table: collection or table
271         :param q_filter: Filter
272         :return: Dict with the number of entries deleted
273         """
274 0         try:
275 0             id_list = []
276 0             with self.lock:
277 0                 for i, _ in self._find(table, self._format_filter(q_filter)):
278 0                     id_list.append(i)
279 0             deleted = len(id_list)
280 0             for i in reversed(id_list):
281 0                 del self.db[table][i]
282 0             return {"deleted": deleted}
283 0         except DbException:
284 0             raise
285 0         except Exception as e:  # TODO refine
286 0             raise DbException(str(e))
287
288 1     def del_one(self, table, q_filter=None, fail_on_empty=True):
289         """
290         Deletes one entry that matches q_filter
291         :param table: collection or table
292         :param q_filter: Filter
293         :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
294         which case it raises a DbException
295         :return: Dict with the number of entries deleted
296         """
297 0         try:
298 0             with self.lock:
299 0                 for i, _ in self._find(table, self._format_filter(q_filter)):
300 0                     break
301                 else:
302 0                     if fail_on_empty:
303 0                         raise DbException(
304                             "Not found entry with filter='{}'".format(q_filter),
305                             HTTPStatus.NOT_FOUND,
306                         )
307 0                     return None
308 0                 del self.db[table][i]
309 0             return {"deleted": 1}
310 0         except Exception as e:  # TODO refine
311 0             raise DbException(str(e))
312
313 1     def _update(
314         self,
315         db_item,
316         update_dict,
317         unset=None,
318         pull=None,
319         push=None,
320         push_list=None,
321         pull_list=None,
322     ):
323         """
324         Modifies an entry at database
325         :param db_item: entry of the table to update
326         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
327         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
328                       ignored. If not exist, it is ignored
329         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
330                      if exist in the array is removed. If not exist, it is ignored
331         :param pull_list: Same as pull but values are arrays where each item is removed from the array
332         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
333                      is appended to the end of the array
334         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
335                           whole array
336         :return: True if database has been changed, False if not; Exception on error
337         """
338
339 1         def _iterate_keys(k, db_nested, populate=True):
340 1             k_list = k.split(".")
341 1             k_item_prev = k_list[0]
342 1             populated = False
343 1             if k_item_prev not in db_nested and populate:
344 1                 populated = True
345 1                 db_nested[k_item_prev] = None
346 1             for k_item in k_list[1:]:
347 1                 if isinstance(db_nested[k_item_prev], dict):
348 1                     if k_item not in db_nested[k_item_prev]:
349 1                         if not populate:
350 1                             raise DbException(
351                                 "Cannot set '{}', not existing '{}'".format(k, k_item)
352                             )
353 1                         populated = True
354 1                         db_nested[k_item_prev][k_item] = None
355 1                 elif isinstance(db_nested[k_item_prev], list) and k_item.isdigit():
356                     # extend list with Nones if index greater than list
357 1                     k_item = int(k_item)
358 1                     if k_item >= len(db_nested[k_item_prev]):
359 1                         if not populate:
360 1                             raise DbException(
361                                 "Cannot set '{}', index too large '{}'".format(
362                                     k, k_item
363                                 )
364                             )
365 1                         populated = True
366 1                         db_nested[k_item_prev] += [None] * (
367                             k_item - len(db_nested[k_item_prev]) + 1
368                         )
369 1                 elif db_nested[k_item_prev] is None:
370 1                     if not populate:
371 0                         raise DbException(
372                             "Cannot set '{}', not existing '{}'".format(k, k_item)
373                         )
374 1                     populated = True
375 1                     db_nested[k_item_prev] = {k_item: None}
376                 else:  # number, string, boolean, ... or list but with not integer key
377 1                     raise DbException(
378                         "Cannot set '{}' on existing '{}={}'".format(
379                             k, k_item_prev, db_nested[k_item_prev]
380                         )
381                     )
382 1                 db_nested = db_nested[k_item_prev]
383 1                 k_item_prev = k_item
384 1             return db_nested, k_item_prev, populated
385
386 1         updated = False
387 1         try:
388 1             if update_dict:
389 1                 for dot_k, v in update_dict.items():
390 1                     dict_to_update, key_to_update, _ = _iterate_keys(dot_k, db_item)
391 1                     dict_to_update[key_to_update] = v
392 1                     updated = True
393 1             if unset:
394 1                 for dot_k in unset:
395 1                     try:
396 1                         dict_to_update, key_to_update, _ = _iterate_keys(
397                             dot_k, db_item, populate=False
398                         )
399 1                         del dict_to_update[key_to_update]
400 1                         updated = True
401 1                     except Exception as unset_error:
402 1                         self.logger.error(f"{unset_error} occured while updating DB.")
403 1             if pull:
404 1                 for dot_k, v in pull.items():
405 1                     try:
406 1                         dict_to_update, key_to_update, _ = _iterate_keys(
407                             dot_k, db_item, populate=False
408                         )
409 1                     except Exception as pull_error:
410 1                         self.logger.error(f"{pull_error} occured while updating DB.")
411 1                         continue
412
413 1                     if key_to_update not in dict_to_update:
414 1                         continue
415 1                     if not isinstance(dict_to_update[key_to_update], list):
416 1                         raise DbException(
417                             "Cannot pull '{}'. Target is not a list".format(dot_k)
418                         )
419 1                     while v in dict_to_update[key_to_update]:
420 1                         dict_to_update[key_to_update].remove(v)
421 1                         updated = True
422 1             if pull_list:
423 0                 for dot_k, v in pull_list.items():
424 0                     if not isinstance(v, list):
425 0                         raise DbException(
426                             "Invalid content at pull_list, '{}' must be an array".format(
427                                 dot_k
428                             ),
429                             http_code=HTTPStatus.BAD_REQUEST,
430                         )
431 0                     try:
432 0                         dict_to_update, key_to_update, _ = _iterate_keys(
433                             dot_k, db_item, populate=False
434                         )
435 0                     except Exception as iterate_error:
436 0                         self.logger.error(
437                             f"{iterate_error} occured while iterating keys in db update."
438                         )
439 0                         continue
440
441 0                     if key_to_update not in dict_to_update:
442 0                         continue
443 0                     if not isinstance(dict_to_update[key_to_update], list):
444 0                         raise DbException(
445                             "Cannot pull_list '{}'. Target is not a list".format(dot_k)
446                         )
447 0                     for single_v in v:
448 0                         while single_v in dict_to_update[key_to_update]:
449 0                             dict_to_update[key_to_update].remove(single_v)
450 0                             updated = True
451 1             if push:
452 1                 for dot_k, v in push.items():
453 1                     dict_to_update, key_to_update, populated = _iterate_keys(
454                         dot_k, db_item
455                     )
456 1                     if (
457                         isinstance(dict_to_update, dict)
458                         and key_to_update not in dict_to_update
459                     ):
460 0                         dict_to_update[key_to_update] = [v]
461 0                         updated = True
462 1                     elif populated and dict_to_update[key_to_update] is None:
463 1                         dict_to_update[key_to_update] = [v]
464 1                         updated = True
465 1                     elif not isinstance(dict_to_update[key_to_update], list):
466 1                         raise DbException(
467                             "Cannot push '{}'. Target is not a list".format(dot_k)
468                         )
469                     else:
470 1                         dict_to_update[key_to_update].append(v)
471 1                         updated = True
472 1             if push_list:
473 1                 for dot_k, v in push_list.items():
474 1                     if not isinstance(v, list):
475 1                         raise DbException(
476                             "Invalid content at push_list, '{}' must be an array".format(
477                                 dot_k
478                             ),
479                             http_code=HTTPStatus.BAD_REQUEST,
480                         )
481 1                     dict_to_update, key_to_update, populated = _iterate_keys(
482                         dot_k, db_item
483                     )
484 1                     if (
485                         isinstance(dict_to_update, dict)
486                         and key_to_update not in dict_to_update
487                     ):
488 0                         dict_to_update[key_to_update] = v.copy()
489 0                         updated = True
490 1                     elif populated and dict_to_update[key_to_update] is None:
491 1                         dict_to_update[key_to_update] = v.copy()
492 1                         updated = True
493 1                     elif not isinstance(dict_to_update[key_to_update], list):
494 1                         raise DbException(
495                             "Cannot push '{}'. Target is not a list".format(dot_k),
496                             http_code=HTTPStatus.CONFLICT,
497                         )
498                     else:
499 1                         dict_to_update[key_to_update] += v
500 1                         updated = True
501
502 1             return updated
503 1         except DbException:
504 1             raise
505 0         except Exception as e:  # TODO refine
506 0             raise DbException(str(e))
507
508 1     def set_one(
509         self,
510         table,
511         q_filter,
512         update_dict,
513         fail_on_empty=True,
514         unset=None,
515         pull=None,
516         push=None,
517         push_list=None,
518         pull_list=None,
519     ):
520         """
521         Modifies an entry at database
522         :param table: collection or table
523         :param q_filter: Filter
524         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
525         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
526         it raises a DbException
527         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
528                       ignored. If not exist, it is ignored
529         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
530                      if exist in the array is removed. If not exist, it is ignored
531         :param pull_list: Same as pull but values are arrays where each item is removed from the array
532         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
533                      is appended to the end of the array
534         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
535                           whole array
536         :return: Dict with the number of entries modified. None if no matching is found.
537         """
538 1         with self.lock:
539 1             for i, db_item in self._find(table, self._format_filter(q_filter)):
540 1                 updated = self._update(
541                     db_item,
542                     update_dict,
543                     unset=unset,
544                     pull=pull,
545                     push=push,
546                     push_list=push_list,
547                     pull_list=pull_list,
548                 )
549 1                 return {"updated": 1 if updated else 0}
550             else:
551 0                 if fail_on_empty:
552 0                     raise DbException(
553                         "Not found entry with _id='{}'".format(q_filter),
554                         HTTPStatus.NOT_FOUND,
555                     )
556 0                 return None
557
558 1     def set_list(
559         self,
560         table,
561         q_filter,
562         update_dict,
563         unset=None,
564         pull=None,
565         push=None,
566         push_list=None,
567         pull_list=None,
568     ):
569         """Modifies al matching entries at database. Same as push. Do not fail if nothing matches"""
570 0         with self.lock:
571 0             updated = 0
572 0             found = 0
573 0             for _, db_item in self._find(table, self._format_filter(q_filter)):
574 0                 found += 1
575 0                 if self._update(
576                     db_item,
577                     update_dict,
578                     unset=unset,
579                     pull=pull,
580                     push=push,
581                     push_list=push_list,
582                     pull_list=pull_list,
583                 ):
584 0                     updated += 1
585             # if not found and fail_on_empty:
586             #     raise DbException("Not found entry with '{}'".format(q_filter), HTTPStatus.NOT_FOUND)
587 0             return {"updated": updated} if found else None
588
589 1     def replace(self, table, _id, indata, fail_on_empty=True):
590         """
591         Replace the content of an entry
592         :param table: collection or table
593         :param _id: internal database id
594         :param indata: content to replace
595         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
596         it raises a DbException
597         :return: Dict with the number of entries replaced
598         """
599 0         try:
600 0             with self.lock:
601 0                 for i, _ in self._find(table, self._format_filter({"_id": _id})):
602 0                     break
603                 else:
604 0                     if fail_on_empty:
605 0                         raise DbException(
606                             "Not found entry with _id='{}'".format(_id),
607                             HTTPStatus.NOT_FOUND,
608                         )
609 0                     return None
610 0                 self.db[table][i] = deepcopy(indata)
611 0             return {"updated": 1}
612 0         except DbException:
613 0             raise
614 0         except Exception as e:  # TODO refine
615 0             raise DbException(str(e))
616
617 1     def create(self, table, indata):
618         """
619         Add a new entry at database
620         :param table: collection or table
621         :param indata: content to be added
622         :return: database '_id' of the inserted element. Raises a DbException on error
623         """
624 0         try:
625 0             id = indata.get("_id")
626 0             if not id:
627 0                 id = str(uuid4())
628 0                 indata["_id"] = id
629 0             with self.lock:
630 0                 if table not in self.db:
631 0                     self.db[table] = []
632 0                 self.db[table].append(deepcopy(indata))
633 0             return id
634 0         except Exception as e:  # TODO refine
635 0             raise DbException(str(e))
636
637 1     def create_list(self, table, indata_list):
638         """
639         Add a new entry at database
640         :param table: collection or table
641         :param indata_list: list content to be added
642         :return: list of inserted 'id's. Raises a DbException on error
643         """
644 0         try:
645 0             _ids = []
646 0             with self.lock:
647 0                 for indata in indata_list:
648 0                     _id = indata.get("_id")
649 0                     if not _id:
650 0                         _id = str(uuid4())
651 0                         indata["_id"] = _id
652 0                     with self.lock:
653 0                         if table not in self.db:
654 0                             self.db[table] = []
655 0                         self.db[table].append(deepcopy(indata))
656 0                     _ids.append(_id)
657 0             return _ids
658 0         except Exception as e:  # TODO refine
659 0             raise DbException(str(e))
660
661
662 1 if __name__ == "__main__":
663     # some test code
664 0     db = DbMemory()
665 0     db.create("test", {"_id": 1, "data": 1})
666 0     db.create("test", {"_id": 2, "data": 2})
667 0     db.create("test", {"_id": 3, "data": 3})
668 0     print("must be 3 items:", db.get_list("test"))
669 0     print("must return item 2:", db.get_list("test", {"_id": 2}))
670 0     db.del_one("test", {"_id": 2})
671 0     print("must be emtpy:", db.get_list("test", {"_id": 2}))