blob: e40d0e4a4705dc072befa6b04123c88323e4884e [file] [log] [blame]
tierno87858ca2018-10-08 16:30:15 +02001# -*- 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
tierno5c012612018-04-19 16:01:59 +020018
19import logging
20from pymongo import MongoClient, errors
tierno3054f782018-04-25 16:59:53 +020021from osm_common.dbbase import DbException, DbBase
tierno5c012612018-04-19 16:01:59 +020022from http import HTTPStatus
23from time import time, sleep
tierno6ec13b02018-05-14 11:24:57 +020024from copy import deepcopy
tierno136f2952018-10-19 13:01:03 +020025from base64 import b64decode
tierno2c9794c2020-04-29 10:24:28 +000026from uuid import uuid4
tierno5c012612018-04-19 16:01:59 +020027
28__author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
29
30# TODO consider use this decorator for database access retries
31# @retry_mongocall
32# def retry_mongocall(call):
33# def _retry_mongocall(*args, **kwargs):
34# retry = 1
35# while True:
36# try:
37# return call(*args, **kwargs)
38# except pymongo.AutoReconnect as e:
39# if retry == 4:
tierno87858ca2018-10-08 16:30:15 +020040# raise DbException(e)
tierno5c012612018-04-19 16:01:59 +020041# sleep(retry)
42# return _retry_mongocall
43
44
tierno6ec13b02018-05-14 11:24:57 +020045def deep_update(to_update, update_with):
46 """
tierno87858ca2018-10-08 16:30:15 +020047 Similar to deepcopy but recursively with nested dictionaries. 'to_update' dict is updated with a content copy of
48 'update_with' dict recursively
tierno6ec13b02018-05-14 11:24:57 +020049 :param to_update: must be a dictionary to be modified
50 :param update_with: must be a dictionary. It is not changed
51 :return: to_update
52 """
53 for key in update_with:
54 if key in to_update:
55 if isinstance(to_update[key], dict) and isinstance(update_with[key], dict):
56 deep_update(to_update[key], update_with[key])
57 continue
58 to_update[key] = deepcopy(update_with[key])
59 return to_update
60
61
tierno5c012612018-04-19 16:01:59 +020062class DbMongo(DbBase):
63 conn_initial_timout = 120
64 conn_timout = 10
65
tierno1e9a3292018-11-05 18:18:45 +010066 def __init__(self, logger_name='db', lock=False):
67 super().__init__(logger_name, lock)
tierno87858ca2018-10-08 16:30:15 +020068 self.client = None
69 self.db = None
tiernoc5297e42019-12-11 12:32:41 +000070 self.database_key = None
71 self.secret_obtained = False
72 # ^ This is used to know if database serial has been got. Database is inited by NBI, who generates the serial
73 # In case it is not ready when connected, it should be got later on before any decrypt operation
74
75 def get_secret_key(self):
76 if self.secret_obtained:
77 return
78
79 self.secret_key = None
80 if self.database_key:
81 self.set_secret_key(self.database_key)
82 version_data = self.get_one("admin", {"_id": "version"}, fail_on_empty=False, fail_on_more=True)
83 if version_data and version_data.get("serial"):
84 self.set_secret_key(b64decode(version_data["serial"]))
85 self.secret_obtained = True
tierno5c012612018-04-19 16:01:59 +020086
tierno136f2952018-10-19 13:01:03 +020087 def db_connect(self, config, target_version=None):
tierno87858ca2018-10-08 16:30:15 +020088 """
89 Connect to database
90 :param config: Configuration of database
tierno136f2952018-10-19 13:01:03 +020091 :param target_version: if provided it checks if database contains required version, raising exception otherwise.
tierno87858ca2018-10-08 16:30:15 +020092 :return: None or raises DbException on error
93 """
tierno5c012612018-04-19 16:01:59 +020094 try:
95 if "logger_name" in config:
96 self.logger = logging.getLogger(config["logger_name"])
tiernoeef7cb72018-11-12 11:51:49 +010097 master_key = config.get("commonkey") or config.get("masterpassword")
98 if master_key:
tiernoc5297e42019-12-11 12:32:41 +000099 self.database_key = master_key
tiernoeef7cb72018-11-12 11:51:49 +0100100 self.set_secret_key(master_key)
Juanc837a782018-11-16 10:47:46 -0300101 if config.get("uri"):
102 self.client = MongoClient(config["uri"])
Juan89e933d2018-11-12 16:17:08 -0300103 else:
104 self.client = MongoClient(config["host"], config["port"])
tiernocfc52722018-10-23 11:41:49 +0200105 # TODO add as parameters also username=config.get("user"), password=config.get("password"))
106 # when all modules are ready
tierno5c012612018-04-19 16:01:59 +0200107 self.db = self.client[config["name"]]
108 if "loglevel" in config:
109 self.logger.setLevel(getattr(logging, config['loglevel']))
110 # get data to try a connection
111 now = time()
112 while True:
113 try:
tierno136f2952018-10-19 13:01:03 +0200114 version_data = self.get_one("admin", {"_id": "version"}, fail_on_empty=False, fail_on_more=True)
115 # check database status is ok
116 if version_data and version_data.get("status") != 'ENABLED':
117 raise DbException("Wrong database status '{}'".format(version_data.get("status")),
118 http_code=HTTPStatus.INTERNAL_SERVER_ERROR)
119 # check version
120 db_version = None if not version_data else version_data.get("version")
121 if target_version and target_version != db_version:
122 raise DbException("Invalid database version {}. Expected {}".format(db_version, target_version))
123 # get serial
124 if version_data and version_data.get("serial"):
tiernoc5297e42019-12-11 12:32:41 +0000125 self.secret_obtained = True
tierno136f2952018-10-19 13:01:03 +0200126 self.set_secret_key(b64decode(version_data["serial"]))
127 self.logger.info("Connected to database {} version {}".format(config["name"], db_version))
tierno5c012612018-04-19 16:01:59 +0200128 return
129 except errors.ConnectionFailure as e:
130 if time() - now >= self.conn_initial_timout:
131 raise
132 self.logger.info("Waiting to database up {}".format(e))
133 sleep(2)
134 except errors.PyMongoError as e:
tierno87858ca2018-10-08 16:30:15 +0200135 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200136
137 @staticmethod
tierno6ec13b02018-05-14 11:24:57 +0200138 def _format_filter(q_filter):
139 """
tierno87858ca2018-10-08 16:30:15 +0200140 Translate query string q_filter into mongo database filter
tierno6ec13b02018-05-14 11:24:57 +0200141 :param q_filter: Query string content. Follows SOL005 section 4.3.2 guidelines, with the follow extensions and
tiernoaf241062018-08-31 14:53:15 +0200142 differences:
143 It accept ".nq" (not equal) in addition to ".neq".
144 For arrays you can specify index (concrete index must match), nothing (any index may match) or 'ANYINDEX'
145 (two or more matches applies for the same array element). Examples:
146 with database register: {A: [{B: 1, C: 2}, {B: 6, C: 9}]}
147 query 'A.B=6' matches because array A contains one element with B equal to 6
148 query 'A.0.B=6' does no match because index 0 of array A contains B with value 1, but not 6
149 query 'A.B=6&A.C=2' matches because one element of array matches B=6 and other matchesC=2
150 query 'A.ANYINDEX.B=6&A.ANYINDEX.C=2' does not match because it is needed the same element of the
151 array matching both
152
153 Examples of translations from SOL005 to >> mongo # comment
154 A=B; A.eq=B >> A: B # must contain key A and equal to B or be a list that contains B
155 A.cont=B >> A: B
156 A=B&A=C; A=B,C >> A: {$in: [B, C]} # must contain key A and equal to B or C or be a list that contains
157 # B or C
158 A.cont=B&A.cont=C; A.cont=B,C >> A: {$in: [B, C]}
159 A.ncont=B >> A: {$nin: B} # must not contain key A or if present not equal to B or if a list,
160 # it must not not contain B
161 A.ncont=B,C; A.ncont=B&A.ncont=C >> A: {$nin: [B,C]} # must not contain key A or if present not equal
162 # neither B nor C; or if a list, it must not contain neither B nor C
163 A.ne=B&A.ne=C; A.ne=B,C >> A: {$nin: [B, C]}
164 A.gt=B >> A: {$gt: B} # must contain key A and greater than B
165 A.ne=B; A.neq=B >> A: {$ne: B} # must not contain key A or if present not equal to B, or if
166 # an array not contain B
167 A.ANYINDEX.B=C >> A: {$elemMatch: {B=C}
tierno6ec13b02018-05-14 11:24:57 +0200168 :return: database mongo filter
169 """
tierno5c012612018-04-19 16:01:59 +0200170 try:
171 db_filter = {}
tierno87858ca2018-10-08 16:30:15 +0200172 if not q_filter:
173 return db_filter
tierno6ec13b02018-05-14 11:24:57 +0200174 for query_k, query_v in q_filter.items():
tierno5c012612018-04-19 16:01:59 +0200175 dot_index = query_k.rfind(".")
176 if dot_index > 1 and query_k[dot_index+1:] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont",
177 "ncont", "neq"):
tiernob20a9022018-05-22 12:07:05 +0200178 operator = "$" + query_k[dot_index + 1:]
tierno5c012612018-04-19 16:01:59 +0200179 if operator == "$neq":
180 operator = "$ne"
181 k = query_k[:dot_index]
182 else:
183 operator = "$eq"
184 k = query_k
185
186 v = query_v
187 if isinstance(v, list):
188 if operator in ("$eq", "$cont"):
189 operator = "$in"
190 v = query_v
191 elif operator in ("$ne", "$ncont"):
192 operator = "$nin"
193 v = query_v
194 else:
195 v = query_v.join(",")
196
197 if operator in ("$eq", "$cont"):
198 # v cannot be a comma separated list, because operator would have been changed to $in
tierno6ec13b02018-05-14 11:24:57 +0200199 db_v = v
tierno5c012612018-04-19 16:01:59 +0200200 elif operator == "$ncount":
201 # v cannot be a comma separated list, because operator would have been changed to $nin
tierno6ec13b02018-05-14 11:24:57 +0200202 db_v = {"$ne": v}
tierno5c012612018-04-19 16:01:59 +0200203 else:
tierno6ec13b02018-05-14 11:24:57 +0200204 db_v = {operator: v}
205
tiernoaf241062018-08-31 14:53:15 +0200206 # process the ANYINDEX word at k.
tierno6ec13b02018-05-14 11:24:57 +0200207 kleft, _, kright = k.rpartition(".ANYINDEX.")
208 while kleft:
209 k = kleft
210 db_v = {"$elemMatch": {kright: db_v}}
211 kleft, _, kright = k.rpartition(".ANYINDEX.")
212
213 # insert in db_filter
214 # maybe db_filter[k] exist. e.g. in the query string for values between 5 and 8: "a.gt=5&a.lt=8"
215 deep_update(db_filter, {k: db_v})
tierno5c012612018-04-19 16:01:59 +0200216
217 return db_filter
218 except Exception as e:
219 raise DbException("Invalid query string filter at {}:{}. Error: {}".format(query_k, v, e),
220 http_code=HTTPStatus.BAD_REQUEST)
221
tierno87858ca2018-10-08 16:30:15 +0200222 def get_list(self, table, q_filter=None):
223 """
224 Obtain a list of entries matching q_filter
225 :param table: collection or table
226 :param q_filter: Filter
227 :return: a list (can be empty) with the found entries. Raises DbException on error
228 """
tierno5c012612018-04-19 16:01:59 +0200229 try:
tiernob20a9022018-05-22 12:07:05 +0200230 result = []
tierno1e9a3292018-11-05 18:18:45 +0100231 with self.lock:
232 collection = self.db[table]
233 db_filter = self._format_filter(q_filter)
234 rows = collection.find(db_filter)
tierno5c012612018-04-19 16:01:59 +0200235 for row in rows:
tiernob20a9022018-05-22 12:07:05 +0200236 result.append(row)
237 return result
tierno5c012612018-04-19 16:01:59 +0200238 except DbException:
239 raise
240 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200241 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200242
delacruzramoae049d82019-09-17 16:05:17 +0200243 def count(self, table, q_filter=None):
244 """
245 Count the number of entries matching q_filter
246 :param table: collection or table
247 :param q_filter: Filter
248 :return: number of entries found (can be zero)
249 :raise: DbException on error
250 """
251 try:
252 with self.lock:
253 collection = self.db[table]
254 db_filter = self._format_filter(q_filter)
255 count = collection.count(db_filter)
256 return count
257 except DbException:
258 raise
259 except Exception as e: # TODO refine
260 raise DbException(e)
261
tierno87858ca2018-10-08 16:30:15 +0200262 def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
263 """
264 Obtain one entry matching q_filter
265 :param table: collection or table
266 :param q_filter: Filter
267 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
268 it raises a DbException
269 :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
270 that it raises a DbException
271 :return: The requested element, or None
272 """
tierno5c012612018-04-19 16:01:59 +0200273 try:
tierno87858ca2018-10-08 16:30:15 +0200274 db_filter = self._format_filter(q_filter)
tierno1e9a3292018-11-05 18:18:45 +0100275 with self.lock:
276 collection = self.db[table]
277 if not (fail_on_empty and fail_on_more):
278 return collection.find_one(db_filter)
279 rows = collection.find(db_filter)
tierno5c012612018-04-19 16:01:59 +0200280 if rows.count() == 0:
281 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200282 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200283 HTTPStatus.NOT_FOUND)
284 return None
285 elif rows.count() > 1:
286 if fail_on_more:
tierno87858ca2018-10-08 16:30:15 +0200287 raise DbException("Found more than one {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200288 HTTPStatus.CONFLICT)
289 return rows[0]
290 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200291 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200292
tierno87858ca2018-10-08 16:30:15 +0200293 def del_list(self, table, q_filter=None):
294 """
295 Deletes all entries that match q_filter
296 :param table: collection or table
297 :param q_filter: Filter
298 :return: Dict with the number of entries deleted
299 """
tierno5c012612018-04-19 16:01:59 +0200300 try:
tierno1e9a3292018-11-05 18:18:45 +0100301 with self.lock:
302 collection = self.db[table]
303 rows = collection.delete_many(self._format_filter(q_filter))
tierno5c012612018-04-19 16:01:59 +0200304 return {"deleted": rows.deleted_count}
305 except DbException:
306 raise
307 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200308 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200309
tierno87858ca2018-10-08 16:30:15 +0200310 def del_one(self, table, q_filter=None, fail_on_empty=True):
311 """
312 Deletes one entry that matches q_filter
313 :param table: collection or table
314 :param q_filter: Filter
315 :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
316 which case it raises a DbException
317 :return: Dict with the number of entries deleted
318 """
tierno5c012612018-04-19 16:01:59 +0200319 try:
tierno1e9a3292018-11-05 18:18:45 +0100320 with self.lock:
321 collection = self.db[table]
322 rows = collection.delete_one(self._format_filter(q_filter))
tierno5c012612018-04-19 16:01:59 +0200323 if rows.deleted_count == 0:
324 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200325 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200326 HTTPStatus.NOT_FOUND)
327 return None
328 return {"deleted": rows.deleted_count}
329 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200330 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200331
332 def create(self, table, indata):
tierno87858ca2018-10-08 16:30:15 +0200333 """
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 """
tierno5c012612018-04-19 16:01:59 +0200339 try:
tierno1e9a3292018-11-05 18:18:45 +0100340 with self.lock:
341 collection = self.db[table]
342 data = collection.insert_one(indata)
tierno5c012612018-04-19 16:01:59 +0200343 return data.inserted_id
344 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200345 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200346
tierno2c9794c2020-04-29 10:24:28 +0000347 def create_list(self, table, indata_list):
348 """
349 Add several entries at once
350 :param table: collection or table
351 :param indata_list: content list to be added.
352 :return: the list of inserted '_id's. Exception on error
353 """
354 try:
355 for item in indata_list:
356 if item.get("_id") is None:
357 item["_id"] = str(uuid4())
358 with self.lock:
359 collection = self.db[table]
360 data = collection.insert_many(indata_list)
361 return data.inserted_ids
362 except Exception as e: # TODO refine
363 raise DbException(e)
364
tiernod63ea272018-11-27 12:03:36 +0100365 def set_one(self, table, q_filter, update_dict, fail_on_empty=True, unset=None, pull=None, push=None):
tierno87858ca2018-10-08 16:30:15 +0200366 """
367 Modifies an entry at database
368 :param table: collection or table
369 :param q_filter: Filter
370 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
371 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
372 it raises a DbException
tiernod63ea272018-11-27 12:03:36 +0100373 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
374 ignored. If not exist, it is ignored
375 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
376 if exist in the array is removed. If not exist, it is ignored
377 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
378 is appended to the end of the array
tierno87858ca2018-10-08 16:30:15 +0200379 :return: Dict with the number of entries modified. None if no matching is found.
380 """
tierno5c012612018-04-19 16:01:59 +0200381 try:
tiernod63ea272018-11-27 12:03:36 +0100382 db_oper = {}
383 if update_dict:
384 db_oper["$set"] = update_dict
385 if unset:
386 db_oper["$unset"] = unset
387 if pull:
388 db_oper["$pull"] = pull
389 if push:
390 db_oper["$push"] = push
391
tierno1e9a3292018-11-05 18:18:45 +0100392 with self.lock:
393 collection = self.db[table]
tiernod63ea272018-11-27 12:03:36 +0100394 rows = collection.update_one(self._format_filter(q_filter), db_oper)
tierno3054f782018-04-25 16:59:53 +0200395 if rows.matched_count == 0:
tierno5c012612018-04-19 16:01:59 +0200396 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200397 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200398 HTTPStatus.NOT_FOUND)
399 return None
tierno3054f782018-04-25 16:59:53 +0200400 return {"modified": rows.modified_count}
tierno5c012612018-04-19 16:01:59 +0200401 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200402 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200403
delacruzramof71fcff2020-02-11 11:14:07 +0000404 def set_list(self, table, q_filter, update_dict, unset=None, pull=None, push=None):
tierno87858ca2018-10-08 16:30:15 +0200405 """
406 Modifies al matching entries at database
407 :param table: collection or table
408 :param q_filter: Filter
409 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
delacruzramof71fcff2020-02-11 11:14:07 +0000410 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
411 ignored. If not exist, it is ignored
412 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
413 if exist in the array is removed. If not exist, it is ignored
414 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
415 is appended to the end of the array
tierno87858ca2018-10-08 16:30:15 +0200416 :return: Dict with the number of entries modified
417 """
tierno5c012612018-04-19 16:01:59 +0200418 try:
delacruzramof71fcff2020-02-11 11:14:07 +0000419 db_oper = {}
420 if update_dict:
421 db_oper["$set"] = update_dict
422 if unset:
423 db_oper["$unset"] = unset
424 if pull:
425 db_oper["$pull"] = pull
426 if push:
427 db_oper["$push"] = push
tierno1e9a3292018-11-05 18:18:45 +0100428 with self.lock:
429 collection = self.db[table]
delacruzramof71fcff2020-02-11 11:14:07 +0000430 rows = collection.update_many(self._format_filter(q_filter), db_oper)
tierno87858ca2018-10-08 16:30:15 +0200431 return {"modified": rows.modified_count}
432 except Exception as e: # TODO refine
433 raise DbException(e)
434
435 def replace(self, table, _id, indata, fail_on_empty=True):
436 """
437 Replace the content of an entry
438 :param table: collection or table
439 :param _id: internal database id
440 :param indata: content to replace
441 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
442 it raises a DbException
443 :return: Dict with the number of entries replaced
444 """
445 try:
446 db_filter = {"_id": _id}
tierno1e9a3292018-11-05 18:18:45 +0100447 with self.lock:
448 collection = self.db[table]
449 rows = collection.replace_one(db_filter, indata)
tierno5c012612018-04-19 16:01:59 +0200450 if rows.matched_count == 0:
451 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200452 raise DbException("Not found any {} with _id='{}'".format(table[:-1], _id), HTTPStatus.NOT_FOUND)
tierno5c012612018-04-19 16:01:59 +0200453 return None
tierno3054f782018-04-25 16:59:53 +0200454 return {"replaced": rows.modified_count}
tierno5c012612018-04-19 16:01:59 +0200455 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200456 raise DbException(e)