blob: 6eb5ef50d8e11df539c42445cf3b6788fefaa88e [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
tierno5c012612018-04-19 16:01:59 +020026
27__author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
28
29# TODO consider use this decorator for database access retries
30# @retry_mongocall
31# def retry_mongocall(call):
32# def _retry_mongocall(*args, **kwargs):
33# retry = 1
34# while True:
35# try:
36# return call(*args, **kwargs)
37# except pymongo.AutoReconnect as e:
38# if retry == 4:
tierno87858ca2018-10-08 16:30:15 +020039# raise DbException(e)
tierno5c012612018-04-19 16:01:59 +020040# sleep(retry)
41# return _retry_mongocall
42
43
tierno6ec13b02018-05-14 11:24:57 +020044def deep_update(to_update, update_with):
45 """
tierno87858ca2018-10-08 16:30:15 +020046 Similar to deepcopy but recursively with nested dictionaries. 'to_update' dict is updated with a content copy of
47 'update_with' dict recursively
tierno6ec13b02018-05-14 11:24:57 +020048 :param to_update: must be a dictionary to be modified
49 :param update_with: must be a dictionary. It is not changed
50 :return: to_update
51 """
52 for key in update_with:
53 if key in to_update:
54 if isinstance(to_update[key], dict) and isinstance(update_with[key], dict):
55 deep_update(to_update[key], update_with[key])
56 continue
57 to_update[key] = deepcopy(update_with[key])
58 return to_update
59
60
tierno5c012612018-04-19 16:01:59 +020061class DbMongo(DbBase):
62 conn_initial_timout = 120
63 conn_timout = 10
64
tierno1e9a3292018-11-05 18:18:45 +010065 def __init__(self, logger_name='db', lock=False):
66 super().__init__(logger_name, lock)
tierno87858ca2018-10-08 16:30:15 +020067 self.client = None
68 self.db = None
tiernoc5297e42019-12-11 12:32:41 +000069 self.database_key = None
70 self.secret_obtained = False
71 # ^ This is used to know if database serial has been got. Database is inited by NBI, who generates the serial
72 # In case it is not ready when connected, it should be got later on before any decrypt operation
73
74 def get_secret_key(self):
75 if self.secret_obtained:
76 return
77
78 self.secret_key = None
79 if self.database_key:
80 self.set_secret_key(self.database_key)
81 version_data = self.get_one("admin", {"_id": "version"}, fail_on_empty=False, fail_on_more=True)
82 if version_data and version_data.get("serial"):
83 self.set_secret_key(b64decode(version_data["serial"]))
84 self.secret_obtained = True
tierno5c012612018-04-19 16:01:59 +020085
tierno136f2952018-10-19 13:01:03 +020086 def db_connect(self, config, target_version=None):
tierno87858ca2018-10-08 16:30:15 +020087 """
88 Connect to database
89 :param config: Configuration of database
tierno136f2952018-10-19 13:01:03 +020090 :param target_version: if provided it checks if database contains required version, raising exception otherwise.
tierno87858ca2018-10-08 16:30:15 +020091 :return: None or raises DbException on error
92 """
tierno5c012612018-04-19 16:01:59 +020093 try:
94 if "logger_name" in config:
95 self.logger = logging.getLogger(config["logger_name"])
tiernoeef7cb72018-11-12 11:51:49 +010096 master_key = config.get("commonkey") or config.get("masterpassword")
97 if master_key:
tiernoc5297e42019-12-11 12:32:41 +000098 self.database_key = master_key
tiernoeef7cb72018-11-12 11:51:49 +010099 self.set_secret_key(master_key)
Juanc837a782018-11-16 10:47:46 -0300100 if config.get("uri"):
101 self.client = MongoClient(config["uri"])
Juan89e933d2018-11-12 16:17:08 -0300102 else:
103 self.client = MongoClient(config["host"], config["port"])
tiernocfc52722018-10-23 11:41:49 +0200104 # TODO add as parameters also username=config.get("user"), password=config.get("password"))
105 # when all modules are ready
tierno5c012612018-04-19 16:01:59 +0200106 self.db = self.client[config["name"]]
107 if "loglevel" in config:
108 self.logger.setLevel(getattr(logging, config['loglevel']))
109 # get data to try a connection
110 now = time()
111 while True:
112 try:
tierno136f2952018-10-19 13:01:03 +0200113 version_data = self.get_one("admin", {"_id": "version"}, fail_on_empty=False, fail_on_more=True)
114 # check database status is ok
115 if version_data and version_data.get("status") != 'ENABLED':
116 raise DbException("Wrong database status '{}'".format(version_data.get("status")),
117 http_code=HTTPStatus.INTERNAL_SERVER_ERROR)
118 # check version
119 db_version = None if not version_data else version_data.get("version")
120 if target_version and target_version != db_version:
121 raise DbException("Invalid database version {}. Expected {}".format(db_version, target_version))
122 # get serial
123 if version_data and version_data.get("serial"):
tiernoc5297e42019-12-11 12:32:41 +0000124 self.secret_obtained = True
tierno136f2952018-10-19 13:01:03 +0200125 self.set_secret_key(b64decode(version_data["serial"]))
126 self.logger.info("Connected to database {} version {}".format(config["name"], db_version))
tierno5c012612018-04-19 16:01:59 +0200127 return
128 except errors.ConnectionFailure as e:
129 if time() - now >= self.conn_initial_timout:
130 raise
131 self.logger.info("Waiting to database up {}".format(e))
132 sleep(2)
133 except errors.PyMongoError as e:
tierno87858ca2018-10-08 16:30:15 +0200134 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200135
136 @staticmethod
tierno6ec13b02018-05-14 11:24:57 +0200137 def _format_filter(q_filter):
138 """
tierno87858ca2018-10-08 16:30:15 +0200139 Translate query string q_filter into mongo database filter
tierno6ec13b02018-05-14 11:24:57 +0200140 :param q_filter: Query string content. Follows SOL005 section 4.3.2 guidelines, with the follow extensions and
tiernoaf241062018-08-31 14:53:15 +0200141 differences:
142 It accept ".nq" (not equal) in addition to ".neq".
143 For arrays you can specify index (concrete index must match), nothing (any index may match) or 'ANYINDEX'
144 (two or more matches applies for the same array element). Examples:
145 with database register: {A: [{B: 1, C: 2}, {B: 6, C: 9}]}
146 query 'A.B=6' matches because array A contains one element with B equal to 6
147 query 'A.0.B=6' does no match because index 0 of array A contains B with value 1, but not 6
148 query 'A.B=6&A.C=2' matches because one element of array matches B=6 and other matchesC=2
149 query 'A.ANYINDEX.B=6&A.ANYINDEX.C=2' does not match because it is needed the same element of the
150 array matching both
151
152 Examples of translations from SOL005 to >> mongo # comment
153 A=B; A.eq=B >> A: B # must contain key A and equal to B or be a list that contains B
154 A.cont=B >> A: B
155 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
156 # B or C
157 A.cont=B&A.cont=C; A.cont=B,C >> A: {$in: [B, C]}
158 A.ncont=B >> A: {$nin: B} # must not contain key A or if present not equal to B or if a list,
159 # it must not not contain B
160 A.ncont=B,C; A.ncont=B&A.ncont=C >> A: {$nin: [B,C]} # must not contain key A or if present not equal
161 # neither B nor C; or if a list, it must not contain neither B nor C
162 A.ne=B&A.ne=C; A.ne=B,C >> A: {$nin: [B, C]}
163 A.gt=B >> A: {$gt: B} # must contain key A and greater than B
164 A.ne=B; A.neq=B >> A: {$ne: B} # must not contain key A or if present not equal to B, or if
165 # an array not contain B
166 A.ANYINDEX.B=C >> A: {$elemMatch: {B=C}
tierno6ec13b02018-05-14 11:24:57 +0200167 :return: database mongo filter
168 """
tierno5c012612018-04-19 16:01:59 +0200169 try:
170 db_filter = {}
tierno87858ca2018-10-08 16:30:15 +0200171 if not q_filter:
172 return db_filter
tierno6ec13b02018-05-14 11:24:57 +0200173 for query_k, query_v in q_filter.items():
tierno5c012612018-04-19 16:01:59 +0200174 dot_index = query_k.rfind(".")
175 if dot_index > 1 and query_k[dot_index+1:] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont",
176 "ncont", "neq"):
tiernob20a9022018-05-22 12:07:05 +0200177 operator = "$" + query_k[dot_index + 1:]
tierno5c012612018-04-19 16:01:59 +0200178 if operator == "$neq":
179 operator = "$ne"
180 k = query_k[:dot_index]
181 else:
182 operator = "$eq"
183 k = query_k
184
185 v = query_v
186 if isinstance(v, list):
187 if operator in ("$eq", "$cont"):
188 operator = "$in"
189 v = query_v
190 elif operator in ("$ne", "$ncont"):
191 operator = "$nin"
192 v = query_v
193 else:
194 v = query_v.join(",")
195
196 if operator in ("$eq", "$cont"):
197 # v cannot be a comma separated list, because operator would have been changed to $in
tierno6ec13b02018-05-14 11:24:57 +0200198 db_v = v
tierno5c012612018-04-19 16:01:59 +0200199 elif operator == "$ncount":
200 # v cannot be a comma separated list, because operator would have been changed to $nin
tierno6ec13b02018-05-14 11:24:57 +0200201 db_v = {"$ne": v}
tierno5c012612018-04-19 16:01:59 +0200202 else:
tierno6ec13b02018-05-14 11:24:57 +0200203 db_v = {operator: v}
204
tiernoaf241062018-08-31 14:53:15 +0200205 # process the ANYINDEX word at k.
tierno6ec13b02018-05-14 11:24:57 +0200206 kleft, _, kright = k.rpartition(".ANYINDEX.")
207 while kleft:
208 k = kleft
209 db_v = {"$elemMatch": {kright: db_v}}
210 kleft, _, kright = k.rpartition(".ANYINDEX.")
211
212 # insert in db_filter
213 # maybe db_filter[k] exist. e.g. in the query string for values between 5 and 8: "a.gt=5&a.lt=8"
214 deep_update(db_filter, {k: db_v})
tierno5c012612018-04-19 16:01:59 +0200215
216 return db_filter
217 except Exception as e:
218 raise DbException("Invalid query string filter at {}:{}. Error: {}".format(query_k, v, e),
219 http_code=HTTPStatus.BAD_REQUEST)
220
tierno87858ca2018-10-08 16:30:15 +0200221 def get_list(self, table, q_filter=None):
222 """
223 Obtain a list of entries matching q_filter
224 :param table: collection or table
225 :param q_filter: Filter
226 :return: a list (can be empty) with the found entries. Raises DbException on error
227 """
tierno5c012612018-04-19 16:01:59 +0200228 try:
tiernob20a9022018-05-22 12:07:05 +0200229 result = []
tierno1e9a3292018-11-05 18:18:45 +0100230 with self.lock:
231 collection = self.db[table]
232 db_filter = self._format_filter(q_filter)
233 rows = collection.find(db_filter)
tierno5c012612018-04-19 16:01:59 +0200234 for row in rows:
tiernob20a9022018-05-22 12:07:05 +0200235 result.append(row)
236 return result
tierno5c012612018-04-19 16:01:59 +0200237 except DbException:
238 raise
239 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200240 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200241
delacruzramoae049d82019-09-17 16:05:17 +0200242 def count(self, table, q_filter=None):
243 """
244 Count the number of entries matching q_filter
245 :param table: collection or table
246 :param q_filter: Filter
247 :return: number of entries found (can be zero)
248 :raise: DbException on error
249 """
250 try:
251 with self.lock:
252 collection = self.db[table]
253 db_filter = self._format_filter(q_filter)
254 count = collection.count(db_filter)
255 return count
256 except DbException:
257 raise
258 except Exception as e: # TODO refine
259 raise DbException(e)
260
tierno87858ca2018-10-08 16:30:15 +0200261 def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
262 """
263 Obtain one entry matching q_filter
264 :param table: collection or table
265 :param q_filter: Filter
266 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
267 it raises a DbException
268 :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
269 that it raises a DbException
270 :return: The requested element, or None
271 """
tierno5c012612018-04-19 16:01:59 +0200272 try:
tierno87858ca2018-10-08 16:30:15 +0200273 db_filter = self._format_filter(q_filter)
tierno1e9a3292018-11-05 18:18:45 +0100274 with self.lock:
275 collection = self.db[table]
276 if not (fail_on_empty and fail_on_more):
277 return collection.find_one(db_filter)
278 rows = collection.find(db_filter)
tierno5c012612018-04-19 16:01:59 +0200279 if rows.count() == 0:
280 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200281 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200282 HTTPStatus.NOT_FOUND)
283 return None
284 elif rows.count() > 1:
285 if fail_on_more:
tierno87858ca2018-10-08 16:30:15 +0200286 raise DbException("Found more than one {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200287 HTTPStatus.CONFLICT)
288 return rows[0]
289 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200290 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200291
tierno87858ca2018-10-08 16:30:15 +0200292 def del_list(self, table, q_filter=None):
293 """
294 Deletes all entries that match q_filter
295 :param table: collection or table
296 :param q_filter: Filter
297 :return: Dict with the number of entries deleted
298 """
tierno5c012612018-04-19 16:01:59 +0200299 try:
tierno1e9a3292018-11-05 18:18:45 +0100300 with self.lock:
301 collection = self.db[table]
302 rows = collection.delete_many(self._format_filter(q_filter))
tierno5c012612018-04-19 16:01:59 +0200303 return {"deleted": rows.deleted_count}
304 except DbException:
305 raise
306 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200307 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200308
tierno87858ca2018-10-08 16:30:15 +0200309 def del_one(self, table, q_filter=None, fail_on_empty=True):
310 """
311 Deletes one entry that matches q_filter
312 :param table: collection or table
313 :param q_filter: Filter
314 :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
315 which case it raises a DbException
316 :return: Dict with the number of entries deleted
317 """
tierno5c012612018-04-19 16:01:59 +0200318 try:
tierno1e9a3292018-11-05 18:18:45 +0100319 with self.lock:
320 collection = self.db[table]
321 rows = collection.delete_one(self._format_filter(q_filter))
tierno5c012612018-04-19 16:01:59 +0200322 if rows.deleted_count == 0:
323 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200324 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200325 HTTPStatus.NOT_FOUND)
326 return None
327 return {"deleted": rows.deleted_count}
328 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200329 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200330
331 def create(self, table, indata):
tierno87858ca2018-10-08 16:30:15 +0200332 """
333 Add a new entry at database
334 :param table: collection or table
335 :param indata: content to be added
336 :return: database id of the inserted element. Raises a DbException on error
337 """
tierno5c012612018-04-19 16:01:59 +0200338 try:
tierno1e9a3292018-11-05 18:18:45 +0100339 with self.lock:
340 collection = self.db[table]
341 data = collection.insert_one(indata)
tierno5c012612018-04-19 16:01:59 +0200342 return data.inserted_id
343 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200344 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200345
tiernod63ea272018-11-27 12:03:36 +0100346 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 +0200347 """
348 Modifies an entry at database
349 :param table: collection or table
350 :param q_filter: Filter
351 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
352 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
353 it raises a DbException
tiernod63ea272018-11-27 12:03:36 +0100354 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
355 ignored. If not exist, it is ignored
356 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
357 if exist in the array is removed. If not exist, it is ignored
358 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
359 is appended to the end of the array
tierno87858ca2018-10-08 16:30:15 +0200360 :return: Dict with the number of entries modified. None if no matching is found.
361 """
tierno5c012612018-04-19 16:01:59 +0200362 try:
tiernod63ea272018-11-27 12:03:36 +0100363 db_oper = {}
364 if update_dict:
365 db_oper["$set"] = update_dict
366 if unset:
367 db_oper["$unset"] = unset
368 if pull:
369 db_oper["$pull"] = pull
370 if push:
371 db_oper["$push"] = push
372
tierno1e9a3292018-11-05 18:18:45 +0100373 with self.lock:
374 collection = self.db[table]
tiernod63ea272018-11-27 12:03:36 +0100375 rows = collection.update_one(self._format_filter(q_filter), db_oper)
tierno3054f782018-04-25 16:59:53 +0200376 if rows.matched_count == 0:
tierno5c012612018-04-19 16:01:59 +0200377 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200378 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200379 HTTPStatus.NOT_FOUND)
380 return None
tierno3054f782018-04-25 16:59:53 +0200381 return {"modified": rows.modified_count}
tierno5c012612018-04-19 16:01:59 +0200382 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200383 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200384
tierno87858ca2018-10-08 16:30:15 +0200385 def set_list(self, table, q_filter, update_dict):
386 """
387 Modifies al matching entries at database
388 :param table: collection or table
389 :param q_filter: Filter
390 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
391 :return: Dict with the number of entries modified
392 """
tierno5c012612018-04-19 16:01:59 +0200393 try:
tierno1e9a3292018-11-05 18:18:45 +0100394 with self.lock:
395 collection = self.db[table]
396 rows = collection.update_many(self._format_filter(q_filter), {"$set": update_dict})
tierno87858ca2018-10-08 16:30:15 +0200397 return {"modified": rows.modified_count}
398 except Exception as e: # TODO refine
399 raise DbException(e)
400
401 def replace(self, table, _id, indata, fail_on_empty=True):
402 """
403 Replace the content of an entry
404 :param table: collection or table
405 :param _id: internal database id
406 :param indata: content to replace
407 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
408 it raises a DbException
409 :return: Dict with the number of entries replaced
410 """
411 try:
412 db_filter = {"_id": _id}
tierno1e9a3292018-11-05 18:18:45 +0100413 with self.lock:
414 collection = self.db[table]
415 rows = collection.replace_one(db_filter, indata)
tierno5c012612018-04-19 16:01:59 +0200416 if rows.matched_count == 0:
417 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200418 raise DbException("Not found any {} with _id='{}'".format(table[:-1], _id), HTTPStatus.NOT_FOUND)
tierno5c012612018-04-19 16:01:59 +0200419 return None
tierno3054f782018-04-25 16:59:53 +0200420 return {"replaced": rows.modified_count}
tierno5c012612018-04-19 16:01:59 +0200421 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200422 raise DbException(e)