Code Coverage

Cobertura Coverage Report > osm_common >

dbmemory.py

Trend

Classes100%
 
Lines36%
   
Conditionals100%
 

File Coverage summary

NameClassesLinesConditionals
dbmemory.py
100%
1/1
36%
122/337
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
dbmemory.py
36%
122/337
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 import logging
19 1 from osm_common.dbbase import DbException, DbBase
20 1 from osm_common.dbmongo import deep_update
21 1 from http import HTTPStatus
22 1 from uuid import uuid4
23 1 from copy import deepcopy
24
25 1 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
26
27
28 1 class DbMemory(DbBase):
29
30 1     def __init__(self, logger_name='db', lock=False):
31 1         super().__init__(logger_name, lock)
32 1         self.db = {}
33
34 1     def db_connect(self, config):
35         """
36         Connect to database
37         :param config: Configuration of database
38         :return: None or raises DbException on error
39         """
40 1         if "logger_name" in config:
41 1             self.logger = logging.getLogger(config["logger_name"])
42 1         master_key = config.get("commonkey") or config.get("masterpassword")
43 1         if master_key:
44 0             self.set_secret_key(master_key)
45
46 1     @staticmethod
47     def _format_filter(q_filter):
48 1         db_filter = {}
49         # split keys with ANYINDEX in this way:
50         # {"A.B.ANYINDEX.C.D.ANYINDEX.E": v }  -> {"A.B.ANYINDEX": {"C.D.ANYINDEX": {"E": v}}}
51 1         if q_filter:
52 0             for k, v in q_filter.items():
53 0                 db_v = v
54 0                 kleft, _, kright = k.rpartition(".ANYINDEX.")
55 0                 while kleft:
56 0                     k = kleft + ".ANYINDEX"
57 0                     db_v = {kright: db_v}
58 0                     kleft, _, kright = k.rpartition(".ANYINDEX.")
59 0                 deep_update(db_filter, {k: db_v})
60
61 1         return db_filter
62
63 1     def _find(self, table, q_filter):
64
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(content_item in target for content_item in content)
72 0                             return content in target
73 0                         elif isinstance(content, list):
74 0                             return target in content
75                         else:
76 0                             return content == target
77 0                     elif oper in ("neq", "ne", "ncont"):
78 0                         if isinstance(target, list):
79 0                             if isinstance(content, list):
80 0                                 return all(content_item not in target for content_item in content)
81 0                             return content not in target
82 0                         elif isinstance(content, list):
83 0                             return target not in content
84                         else:
85 0                             return content != target
86 0                     if oper == "gt":
87 0                         return content > target
88 0                     elif oper == "gte":
89 0                         return content >= target
90 0                     elif oper == "lt":
91 0                         return content < target
92 0                     elif oper == "lte":
93 0                         return content <= target
94                     else:
95 0                         raise DbException("Unknown filter operator '{}' in key '{}'".
96                                           format(oper, ".".join(key_list)), http_code=HTTPStatus.BAD_REQUEST)
97 0                 except TypeError:
98 0                     return False
99
100 0             elif isinstance(content, dict):
101 0                 return recursive_find(key_list, key_next_index + 1, content.get(key_list[key_next_index]), oper,
102                                       target)
103 0             elif isinstance(content, list):
104 0                 look_for_match = True  # when there is a match return immediately
105 0                 if (target is None) != (oper in ("neq", "ne", "ncont")):  # one True and other False (Xor)
106 0                     look_for_match = False  # when there is not a match return immediately
107
108 0                 for content_item in content:
109 0                     if key_list[key_next_index] == "ANYINDEX" and isinstance(v, dict):
110 0                         matches = True
111 0                         for k2, v2 in target.items():
112 0                             k_new_list = k2.split(".")
113 0                             new_operator = "eq"
114 0                             if k_new_list[-1] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont", "ncont", "neq"):
115 0                                 new_operator = k_new_list.pop()
116 0                             if not recursive_find(k_new_list, 0, content_item, new_operator, v2):
117 0                                 matches = False
118 0                                 break
119
120                     else:
121 0                         matches = recursive_find(key_list, key_next_index, content_item, oper, target)
122 0                     if matches == look_for_match:
123 0                         return matches
124 0                 if key_list[key_next_index].isdecimal() and int(key_list[key_next_index]) < len(content):
125 0                     matches = recursive_find(key_list, key_next_index + 1, content[int(key_list[key_next_index])],
126                                              oper, target)
127 0                     if matches == look_for_match:
128 0                         return matches
129 0                 return not look_for_match
130             else:  # content is not dict, nor list neither None, so not found
131 0                 if oper in ("neq", "ne", "ncont"):
132 0                     return target is not None
133                 else:
134 0                     return target is None
135
136 0         for i, row in enumerate(self.db.get(table, ())):
137 0             q_filter = q_filter or {}
138 0             for k, v in q_filter.items():
139 0                 k_list = k.split(".")
140 0                 operator = "eq"
141 0                 if k_list[-1] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont", "ncont", "neq"):
142 0                     operator = k_list.pop()
143 0                 matches = recursive_find(k_list, 0, row, operator, v)
144 0                 if not matches:
145 0                     break
146             else:
147                 # match
148 0                 yield i, row
149
150 1     def get_list(self, table, q_filter=None):
151         """
152         Obtain a list of entries matching q_filter
153         :param table: collection or table
154         :param q_filter: Filter
155         :return: a list (can be empty) with the found entries. Raises DbException on error
156         """
157 0         try:
158 0             result = []
159 0             with self.lock:
160 0                 for _, row in self._find(table, self._format_filter(q_filter)):
161 0                     result.append(deepcopy(row))
162 0             return result
163 0         except DbException:
164 0             raise
165 0         except Exception as e:  # TODO refine
166 0             raise DbException(str(e))
167
168 1     def count(self, table, q_filter=None):
169         """
170         Count the number of entries matching q_filter
171         :param table: collection or table
172         :param q_filter: Filter
173         :return: number of entries found (can be zero)
174         :raise: DbException on error
175         """
176 0         try:
177 0             with self.lock:
178 0                 return sum(1 for x in self._find(table, self._format_filter(q_filter)))
179 0         except DbException:
180 0             raise
181 0         except Exception as e:  # TODO refine
182 0             raise DbException(str(e))
183
184 1     def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
185         """
186         Obtain one entry matching q_filter
187         :param table: collection or table
188         :param q_filter: Filter
189         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
190         it raises a DbException
191         :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
192         that it raises a DbException
193         :return: The requested element, or None
194         """
195 0         try:
196 0             result = None
197 0             with self.lock:
198 0                 for _, row in self._find(table, self._format_filter(q_filter)):
199 0                     if not fail_on_more:
200 0                         return deepcopy(row)
201 0                     if result:
202 0                         raise DbException("Found more than one entry with filter='{}'".format(q_filter),
203                                           HTTPStatus.CONFLICT.value)
204 0                     result = row
205 0             if not result and fail_on_empty:
206 0                 raise DbException("Not found entry with filter='{}'".format(q_filter), HTTPStatus.NOT_FOUND)
207 0             return deepcopy(result)
208 0         except Exception as e:  # TODO refine
209 0             raise DbException(str(e))
210
211 1     def del_list(self, table, q_filter=None):
212         """
213         Deletes all entries that match q_filter
214         :param table: collection or table
215         :param q_filter: Filter
216         :return: Dict with the number of entries deleted
217         """
218 0         try:
219 0             id_list = []
220 0             with self.lock:
221 0                 for i, _ in self._find(table, self._format_filter(q_filter)):
222 0                     id_list.append(i)
223 0             deleted = len(id_list)
224 0             for i in reversed(id_list):
225 0                 del self.db[table][i]
226 0             return {"deleted": deleted}
227 0         except DbException:
228 0             raise
229 0         except Exception as e:  # TODO refine
230 0             raise DbException(str(e))
231
232 1     def del_one(self, table, q_filter=None, fail_on_empty=True):
233         """
234         Deletes one entry that matches q_filter
235         :param table: collection or table
236         :param q_filter: Filter
237         :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
238         which case it raises a DbException
239         :return: Dict with the number of entries deleted
240         """
241 0         try:
242 0             with self.lock:
243 0                 for i, _ in self._find(table, self._format_filter(q_filter)):
244 0                     break
245                 else:
246 0                     if fail_on_empty:
247 0                         raise DbException("Not found entry with filter='{}'".format(q_filter), HTTPStatus.NOT_FOUND)
248 0                     return None
249 0                 del self.db[table][i]
250 0             return {"deleted": 1}
251 0         except Exception as e:  # TODO refine
252 0             raise DbException(str(e))
253
254 1     def _update(self, db_item, update_dict, unset=None, pull=None, push=None, push_list=None, pull_list=None):
255         """
256         Modifies an entry at database
257         :param db_item: entry of the table to update
258         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
259         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
260                       ignored. If not exist, it is ignored
261         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
262                      if exist in the array is removed. If not exist, it is ignored
263         :param pull_list: Same as pull but values are arrays where each item is removed from the array
264         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
265                      is appended to the end of the array
266         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
267                           whole array
268         :return: True if database has been changed, False if not; Exception on error
269         """
270 1         def _iterate_keys(k, db_nested, populate=True):
271 1             k_list = k.split(".")
272 1             k_item_prev = k_list[0]
273 1             populated = False
274 1             if k_item_prev not in db_nested and populate:
275 1                 populated = True
276 1                 db_nested[k_item_prev] = None
277 1             for k_item in k_list[1:]:
278 1                 if isinstance(db_nested[k_item_prev], dict):
279 1                     if k_item not in db_nested[k_item_prev]:
280 1                         if not populate:
281 1                             raise DbException("Cannot set '{}', not existing '{}'".format(k, k_item))
282 1                         populated = True
283 1                         db_nested[k_item_prev][k_item] = None
284 1                 elif isinstance(db_nested[k_item_prev], list) and k_item.isdigit():
285                     # extend list with Nones if index greater than list
286 1                     k_item = int(k_item)
287 1                     if k_item >= len(db_nested[k_item_prev]):
288 1                         if not populate:
289 1                             raise DbException("Cannot set '{}', index too large '{}'".format(k, k_item))
290 1                         populated = True
291 1                         db_nested[k_item_prev] += [None] * (k_item - len(db_nested[k_item_prev]) + 1)
292 1                 elif db_nested[k_item_prev] is None:
293 1                     if not populate:
294 0                         raise DbException("Cannot set '{}', not existing '{}'".format(k, k_item))
295 1                     populated = True
296 1                     db_nested[k_item_prev] = {k_item: None}
297                 else:  # number, string, boolean, ... or list but with not integer key
298 1                     raise DbException("Cannot set '{}' on existing '{}={}'".format(k, k_item_prev,
299                                                                                    db_nested[k_item_prev]))
300 1                 db_nested = db_nested[k_item_prev]
301 1                 k_item_prev = k_item
302 1             return db_nested, k_item_prev, populated
303
304 1         updated = False
305 1         try:
306 1             if update_dict:
307 1                 for dot_k, v in update_dict.items():
308 1                     dict_to_update, key_to_update, _ = _iterate_keys(dot_k, db_item)
309 1                     dict_to_update[key_to_update] = v
310 1                     updated = True
311 1             if unset:
312 1                 for dot_k in unset:
313 1                     try:
314 1                         dict_to_update, key_to_update, _ = _iterate_keys(dot_k, db_item, populate=False)
315 1                         del dict_to_update[key_to_update]
316 1                         updated = True
317 1                     except Exception:
318 1                         pass
319 1             if pull:
320 1                 for dot_k, v in pull.items():
321 1                     try:
322 1                         dict_to_update, key_to_update, _ = _iterate_keys(dot_k, db_item, populate=False)
323 1                     except Exception:
324 1                         continue
325 1                     if key_to_update not in dict_to_update:
326 1                         continue
327 1                     if not isinstance(dict_to_update[key_to_update], list):
328 1                         raise DbException("Cannot pull '{}'. Target is not a list".format(dot_k))
329 1                     while v in dict_to_update[key_to_update]:
330 1                         dict_to_update[key_to_update].remove(v)
331 1                         updated = True
332 1             if pull_list:
333 0                 for dot_k, v in pull_list.items():
334 0                     if not isinstance(v, list):
335 0                         raise DbException("Invalid content at pull_list, '{}' must be an array".format(dot_k),
336                                           http_code=HTTPStatus.BAD_REQUEST)
337 0                     try:
338 0                         dict_to_update, key_to_update, _ = _iterate_keys(dot_k, db_item, populate=False)
339 0                     except Exception:
340 0                         continue
341 0                     if key_to_update not in dict_to_update:
342 0                         continue
343 0                     if not isinstance(dict_to_update[key_to_update], list):
344 0                         raise DbException("Cannot pull_list '{}'. Target is not a list".format(dot_k))
345 0                     for single_v in v:
346 0                         while single_v in dict_to_update[key_to_update]:
347 0                             dict_to_update[key_to_update].remove(single_v)
348 0                             updated = True
349 1             if push:
350 1                 for dot_k, v in push.items():
351 1                     dict_to_update, key_to_update, populated = _iterate_keys(dot_k, db_item)
352 1                     if isinstance(dict_to_update, dict) and key_to_update not in dict_to_update:
353 0                         dict_to_update[key_to_update] = [v]
354 0                         updated = True
355 1                     elif populated and dict_to_update[key_to_update] is None:
356 1                         dict_to_update[key_to_update] = [v]
357 1                         updated = True
358 1                     elif not isinstance(dict_to_update[key_to_update], list):
359 1                         raise DbException("Cannot push '{}'. Target is not a list".format(dot_k))
360                     else:
361 1                         dict_to_update[key_to_update].append(v)
362 1                         updated = True
363 1             if push_list:
364 1                 for dot_k, v in push_list.items():
365 1                     if not isinstance(v, list):
366 1                         raise DbException("Invalid content at push_list, '{}' must be an array".format(dot_k),
367                                           http_code=HTTPStatus.BAD_REQUEST)
368 1                     dict_to_update, key_to_update, populated = _iterate_keys(dot_k, db_item)
369 1                     if isinstance(dict_to_update, dict) and key_to_update not in dict_to_update:
370 0                         dict_to_update[key_to_update] = v.copy()
371 0                         updated = True
372 1                     elif populated and dict_to_update[key_to_update] is None:
373 1                         dict_to_update[key_to_update] = v.copy()
374 1                         updated = True
375 1                     elif not isinstance(dict_to_update[key_to_update], list):
376 1                         raise DbException("Cannot push '{}'. Target is not a list".format(dot_k),
377                                           http_code=HTTPStatus.CONFLICT)
378                     else:
379 1                         dict_to_update[key_to_update] += v
380 1                         updated = True
381
382 1             return updated
383 1         except DbException:
384 1             raise
385 0         except Exception as e:  # TODO refine
386 0             raise DbException(str(e))
387
388 1     def set_one(self, table, q_filter, update_dict, fail_on_empty=True, unset=None, pull=None, push=None,
389                 push_list=None, pull_list=None):
390         """
391         Modifies an entry at database
392         :param table: collection or table
393         :param q_filter: Filter
394         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
395         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
396         it raises a DbException
397         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
398                       ignored. If not exist, it is ignored
399         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
400                      if exist in the array is removed. If not exist, it is ignored
401         :param pull_list: Same as pull but values are arrays where each item is removed from the array
402         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
403                      is appended to the end of the array
404         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
405                           whole array
406         :return: Dict with the number of entries modified. None if no matching is found.
407         """
408 1         with self.lock:
409 1             for i, db_item in self._find(table, self._format_filter(q_filter)):
410 1                 updated = self._update(db_item, update_dict, unset=unset, pull=pull, push=push, push_list=push_list,
411                                        pull_list=pull_list)
412 1                 return {"updated": 1 if updated else 0}
413             else:
414 0                 if fail_on_empty:
415 0                     raise DbException("Not found entry with _id='{}'".format(q_filter), HTTPStatus.NOT_FOUND)
416 0                 return None
417
418 1     def set_list(self, table, q_filter, update_dict, unset=None, pull=None, push=None, push_list=None, pull_list=None):
419         """Modifies al matching entries at database. Same as push. Do not fail if nothing matches"""
420 0         with self.lock:
421 0             updated = 0
422 0             found = 0
423 0             for _, db_item in self._find(table, self._format_filter(q_filter)):
424 0                 found += 1
425 0                 if self._update(db_item, update_dict, unset=unset, pull=pull, push=push, push_list=push_list,
426                                 pull_list=pull_list):
427 0                     updated += 1
428             # if not found and fail_on_empty:
429             #     raise DbException("Not found entry with '{}'".format(q_filter), HTTPStatus.NOT_FOUND)
430 0             return {"updated": updated} if found else None
431
432 1     def replace(self, table, _id, indata, fail_on_empty=True):
433         """
434         Replace the content of an entry
435         :param table: collection or table
436         :param _id: internal database id
437         :param indata: content to replace
438         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
439         it raises a DbException
440         :return: Dict with the number of entries replaced
441         """
442 0         try:
443 0             with self.lock:
444 0                 for i, _ in self._find(table, self._format_filter({"_id": _id})):
445 0                     break
446                 else:
447 0                     if fail_on_empty:
448 0                         raise DbException("Not found entry with _id='{}'".format(_id), HTTPStatus.NOT_FOUND)
449 0                     return None
450 0                 self.db[table][i] = deepcopy(indata)
451 0             return {"updated": 1}
452 0         except DbException:
453 0             raise
454 0         except Exception as e:  # TODO refine
455 0             raise DbException(str(e))
456
457 1     def create(self, table, indata):
458         """
459         Add a new entry at database
460         :param table: collection or table
461         :param indata: content to be added
462         :return: database '_id' of the inserted element. Raises a DbException on error
463         """
464 0         try:
465 0             id = indata.get("_id")
466 0             if not id:
467 0                 id = str(uuid4())
468 0                 indata["_id"] = id
469 0             with self.lock:
470 0                 if table not in self.db:
471 0                     self.db[table] = []
472 0                 self.db[table].append(deepcopy(indata))
473 0             return id
474 0         except Exception as e:  # TODO refine
475 0             raise DbException(str(e))
476
477 1     def create_list(self, table, indata_list):
478         """
479         Add a new entry at database
480         :param table: collection or table
481         :param indata_list: list content to be added
482         :return: list of inserted 'id's. Raises a DbException on error
483         """
484 0         try:
485 0             _ids = []
486 0             with self.lock:
487 0                 for indata in indata_list:
488 0                     _id = indata.get("_id")
489 0                     if not _id:
490 0                         _id = str(uuid4())
491 0                         indata["_id"] = _id
492 0                     with self.lock:
493 0                         if table not in self.db:
494 0                             self.db[table] = []
495 0                         self.db[table].append(deepcopy(indata))
496 0                     _ids.append(_id)
497 0             return _ids
498 0         except Exception as e:  # TODO refine
499 0             raise DbException(str(e))
500
501
502 1 if __name__ == '__main__':
503     # some test code
504 0     db = DbMemory()
505 0     db.create("test", {"_id": 1, "data": 1})
506 0     db.create("test", {"_id": 2, "data": 2})
507 0     db.create("test", {"_id": 3, "data": 3})
508 0     print("must be 3 items:", db.get_list("test"))
509 0     print("must return item 2:", db.get_list("test", {"_id": 2}))
510 0     db.del_one("test", {"_id": 2})
511 0     print("must be emtpy:", db.get_list("test", {"_id": 2}))