fix in dbmemory query using '.cont' and '.ncont'
[osm/common.git] / 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 import logging
19 from osm_common.dbbase import DbException, DbBase
20 from osm_common.dbmongo import deep_update
21 from http import HTTPStatus
22 from uuid import uuid4
23 from copy import deepcopy
24
25 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
26
27
28 class DbMemory(DbBase):
29
30 def __init__(self, logger_name='db', lock=False):
31 super().__init__(logger_name, lock)
32 self.db = {}
33
34 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 if "logger_name" in config:
41 self.logger = logging.getLogger(config["logger_name"])
42 master_key = config.get("commonkey") or config.get("masterpassword")
43 if master_key:
44 self.set_secret_key(master_key)
45
46 @staticmethod
47 def _format_filter(q_filter):
48 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 if q_filter:
52 for k, v in q_filter.items():
53 db_v = v
54 kleft, _, kright = k.rpartition(".ANYINDEX.")
55 while kleft:
56 k = kleft + ".ANYINDEX"
57 db_v = {kright: db_v}
58 kleft, _, kright = k.rpartition(".ANYINDEX.")
59 deep_update(db_filter, {k: db_v})
60
61 return db_filter
62
63 def _find(self, table, q_filter):
64
65 def recursive_find(key_list, key_next_index, content, oper, target):
66 if key_next_index == len(key_list) or content is None:
67 try:
68 if oper in ("eq", "cont"):
69 if isinstance(target, list):
70 if isinstance(content, list):
71 return any(content_item in target for content_item in content)
72 return content in target
73 elif isinstance(content, list):
74 return target in content
75 else:
76 return content == target
77 elif oper in ("neq", "ne", "ncont"):
78 if isinstance(target, list):
79 if isinstance(content, list):
80 return all(content_item not in target for content_item in content)
81 return content not in target
82 elif isinstance(content, list):
83 return target not in content
84 else:
85 return content != target
86 if oper == "gt":
87 return content > target
88 elif oper == "gte":
89 return content >= target
90 elif oper == "lt":
91 return content < target
92 elif oper == "lte":
93 return content <= target
94 else:
95 raise DbException("Unknown filter operator '{}' in key '{}'".
96 format(oper, ".".join(key_list)), http_code=HTTPStatus.BAD_REQUEST)
97 except TypeError:
98 return False
99
100 elif isinstance(content, dict):
101 return recursive_find(key_list, key_next_index + 1, content.get(key_list[key_next_index]), oper,
102 target)
103 elif isinstance(content, list):
104 look_for_match = True # when there is a match return immediately
105 if (target is None) != (oper in ("neq", "ne", "ncont")): # one True and other False (Xor)
106 look_for_match = False # when there is not a match return immediately
107
108 for content_item in content:
109 if key_list[key_next_index] == "ANYINDEX" and isinstance(v, dict):
110 matches = True
111 for k2, v2 in target.items():
112 k_new_list = k2.split(".")
113 new_operator = "eq"
114 if k_new_list[-1] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont", "ncont", "neq"):
115 new_operator = k_new_list.pop()
116 if not recursive_find(k_new_list, 0, content_item, new_operator, v2):
117 matches = False
118 break
119
120 else:
121 matches = recursive_find(key_list, key_next_index, content_item, oper, target)
122 if matches == look_for_match:
123 return matches
124 if key_list[key_next_index].isdecimal() and int(key_list[key_next_index]) < len(content):
125 matches = recursive_find(key_list, key_next_index + 1, content[int(key_list[key_next_index])],
126 oper, target)
127 if matches == look_for_match:
128 return matches
129 return not look_for_match
130 else: # content is not dict, nor list neither None, so not found
131 if oper in ("neq", "ne", "ncont"):
132 return target is not None
133 else:
134 return target is None
135
136 for i, row in enumerate(self.db.get(table, ())):
137 q_filter = q_filter or {}
138 for k, v in q_filter.items():
139 k_list = k.split(".")
140 operator = "eq"
141 if k_list[-1] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont", "ncont", "neq"):
142 operator = k_list.pop()
143 matches = recursive_find(k_list, 0, row, operator, v)
144 if not matches:
145 break
146 else:
147 # match
148 yield i, row
149
150 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 try:
158 result = []
159 with self.lock:
160 for _, row in self._find(table, self._format_filter(q_filter)):
161 result.append(deepcopy(row))
162 return result
163 except DbException:
164 raise
165 except Exception as e: # TODO refine
166 raise DbException(str(e))
167
168 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 try:
177 with self.lock:
178 return sum(1 for x in self._find(table, self._format_filter(q_filter)))
179 except DbException:
180 raise
181 except Exception as e: # TODO refine
182 raise DbException(str(e))
183
184 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 try:
196 result = None
197 with self.lock:
198 for _, row in self._find(table, self._format_filter(q_filter)):
199 if not fail_on_more:
200 return deepcopy(row)
201 if result:
202 raise DbException("Found more than one entry with filter='{}'".format(q_filter),
203 HTTPStatus.CONFLICT.value)
204 result = row
205 if not result and fail_on_empty:
206 raise DbException("Not found entry with filter='{}'".format(q_filter), HTTPStatus.NOT_FOUND)
207 return deepcopy(result)
208 except Exception as e: # TODO refine
209 raise DbException(str(e))
210
211 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 try:
219 id_list = []
220 with self.lock:
221 for i, _ in self._find(table, self._format_filter(q_filter)):
222 id_list.append(i)
223 deleted = len(id_list)
224 for i in reversed(id_list):
225 del self.db[table][i]
226 return {"deleted": deleted}
227 except DbException:
228 raise
229 except Exception as e: # TODO refine
230 raise DbException(str(e))
231
232 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 try:
242 with self.lock:
243 for i, _ in self._find(table, self._format_filter(q_filter)):
244 break
245 else:
246 if fail_on_empty:
247 raise DbException("Not found entry with filter='{}'".format(q_filter), HTTPStatus.NOT_FOUND)
248 return None
249 del self.db[table][i]
250 return {"deleted": 1}
251 except Exception as e: # TODO refine
252 raise DbException(str(e))
253
254 def set_one(self, table, q_filter, update_dict, fail_on_empty=True, unset=None, pull=None, push=None):
255 """
256 Modifies an entry at database
257 :param table: collection or table
258 :param q_filter: Filter
259 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
260 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
261 it raises a DbException
262 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
263 ignored. If not exist, it is ignored
264 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
265 if exist in the array is removed. If not exist, it is ignored
266 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
267 is appended to the end of the array
268 :return: Dict with the number of entries modified. None if no matching is found.
269 """
270 try:
271 with self.lock:
272 for i, db_item in self._find(table, self._format_filter(q_filter)):
273 break
274 else:
275 if fail_on_empty:
276 raise DbException("Not found entry with _id='{}'".format(q_filter), HTTPStatus.NOT_FOUND)
277 return None
278 for k, v in update_dict.items():
279 db_nested = db_item
280 k_list = k.split(".")
281 k_item_prev = k_list[0]
282 for k_item in k_list[1:]:
283 if isinstance(db_nested[k_item_prev], dict):
284 if k_item not in db_nested[k_item_prev]:
285 db_nested[k_item_prev][k_item] = None
286 elif isinstance(db_nested[k_item_prev], list) and k_item.isdigit():
287 # extend list with Nones if index greater than list
288 k_item = int(k_item)
289 if k_item >= len(db_nested[k_item_prev]):
290 db_nested[k_item_prev] += [None] * (k_item - len(db_nested[k_item_prev]) + 1)
291 elif db_nested[k_item_prev] is None:
292 db_nested[k_item_prev] = {k_item: None}
293 else: # number, string, boolean, ... or list but with not integer key
294 raise DbException("Cannot set '{}' on existing '{}={}'".format(k, k_item_prev,
295 db_nested[k_item_prev]))
296
297 db_nested = db_nested[k_item_prev]
298 k_item_prev = k_item
299
300 db_nested[k_item_prev] = v
301 return {"updated": 1}
302 except DbException:
303 raise
304 except Exception as e: # TODO refine
305 raise DbException(str(e))
306
307 def replace(self, table, _id, indata, fail_on_empty=True):
308 """
309 Replace the content of an entry
310 :param table: collection or table
311 :param _id: internal database id
312 :param indata: content to replace
313 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
314 it raises a DbException
315 :return: Dict with the number of entries replaced
316 """
317 try:
318 with self.lock:
319 for i, _ in self._find(table, self._format_filter({"_id": _id})):
320 break
321 else:
322 if fail_on_empty:
323 raise DbException("Not found entry with _id='{}'".format(_id), HTTPStatus.NOT_FOUND)
324 return None
325 self.db[table][i] = deepcopy(indata)
326 return {"updated": 1}
327 except DbException:
328 raise
329 except Exception as e: # TODO refine
330 raise DbException(str(e))
331
332 def create(self, table, indata):
333 """
334 Add a new entry at database
335 :param table: collection or table
336 :param indata: content to be added
337 :return: database id of the inserted element. Raises a DbException on error
338 """
339 try:
340 id = indata.get("_id")
341 if not id:
342 id = str(uuid4())
343 indata["_id"] = id
344 with self.lock:
345 if table not in self.db:
346 self.db[table] = []
347 self.db[table].append(deepcopy(indata))
348 return id
349 except Exception as e: # TODO refine
350 raise DbException(str(e))
351
352 def create_list(self, table, indata_list):
353 """
354 Add a new entry at database
355 :param table: collection or table
356 :param indata_list: list content to be added
357 :return: database ids of the inserted element. Raises a DbException on error
358 """
359 try:
360 _ids = []
361 with self.lock:
362 for indata in indata_list:
363 _id = indata.get("_id")
364 if not _id:
365 _id = str(uuid4())
366 indata["_id"] = _id
367 with self.lock:
368 if table not in self.db:
369 self.db[table] = []
370 self.db[table].append(deepcopy(indata))
371 _ids.append(_id)
372 return _ids
373 except Exception as e: # TODO refine
374 raise DbException(str(e))
375
376
377 if __name__ == '__main__':
378 # some test code
379 db = DbMemory()
380 db.create("test", {"_id": 1, "data": 1})
381 db.create("test", {"_id": 2, "data": 2})
382 db.create("test", {"_id": 3, "data": 3})
383 print("must be 3 items:", db.get_list("test"))
384 print("must return item 2:", db.get_list("test", {"_id": 2}))
385 db.del_one("test", {"_id": 2})
386 print("must be emtpy:", db.get_list("test", {"_id": 2}))