blob: 86ec7b79c46590ab31b122f2eaa5e6610a4def4a [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
tierno5c012612018-04-19 16:01:59 +020069
tierno136f2952018-10-19 13:01:03 +020070 def db_connect(self, config, target_version=None):
tierno87858ca2018-10-08 16:30:15 +020071 """
72 Connect to database
73 :param config: Configuration of database
tierno136f2952018-10-19 13:01:03 +020074 :param target_version: if provided it checks if database contains required version, raising exception otherwise.
tierno87858ca2018-10-08 16:30:15 +020075 :return: None or raises DbException on error
76 """
tierno5c012612018-04-19 16:01:59 +020077 try:
78 if "logger_name" in config:
79 self.logger = logging.getLogger(config["logger_name"])
tiernoeef7cb72018-11-12 11:51:49 +010080 master_key = config.get("commonkey") or config.get("masterpassword")
81 if master_key:
82 self.set_secret_key(master_key)
Juanc837a782018-11-16 10:47:46 -030083 if config.get("uri"):
84 self.client = MongoClient(config["uri"])
Juan89e933d2018-11-12 16:17:08 -030085 else:
86 self.client = MongoClient(config["host"], config["port"])
tiernocfc52722018-10-23 11:41:49 +020087 # TODO add as parameters also username=config.get("user"), password=config.get("password"))
88 # when all modules are ready
tierno5c012612018-04-19 16:01:59 +020089 self.db = self.client[config["name"]]
90 if "loglevel" in config:
91 self.logger.setLevel(getattr(logging, config['loglevel']))
92 # get data to try a connection
93 now = time()
94 while True:
95 try:
tierno136f2952018-10-19 13:01:03 +020096 version_data = self.get_one("admin", {"_id": "version"}, fail_on_empty=False, fail_on_more=True)
97 # check database status is ok
98 if version_data and version_data.get("status") != 'ENABLED':
99 raise DbException("Wrong database status '{}'".format(version_data.get("status")),
100 http_code=HTTPStatus.INTERNAL_SERVER_ERROR)
101 # check version
102 db_version = None if not version_data else version_data.get("version")
103 if target_version and target_version != db_version:
104 raise DbException("Invalid database version {}. Expected {}".format(db_version, target_version))
105 # get serial
106 if version_data and version_data.get("serial"):
107 self.set_secret_key(b64decode(version_data["serial"]))
108 self.logger.info("Connected to database {} version {}".format(config["name"], db_version))
tierno5c012612018-04-19 16:01:59 +0200109 return
110 except errors.ConnectionFailure as e:
111 if time() - now >= self.conn_initial_timout:
112 raise
113 self.logger.info("Waiting to database up {}".format(e))
114 sleep(2)
115 except errors.PyMongoError as e:
tierno87858ca2018-10-08 16:30:15 +0200116 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200117
118 @staticmethod
tierno6ec13b02018-05-14 11:24:57 +0200119 def _format_filter(q_filter):
120 """
tierno87858ca2018-10-08 16:30:15 +0200121 Translate query string q_filter into mongo database filter
tierno6ec13b02018-05-14 11:24:57 +0200122 :param q_filter: Query string content. Follows SOL005 section 4.3.2 guidelines, with the follow extensions and
tiernoaf241062018-08-31 14:53:15 +0200123 differences:
124 It accept ".nq" (not equal) in addition to ".neq".
125 For arrays you can specify index (concrete index must match), nothing (any index may match) or 'ANYINDEX'
126 (two or more matches applies for the same array element). Examples:
127 with database register: {A: [{B: 1, C: 2}, {B: 6, C: 9}]}
128 query 'A.B=6' matches because array A contains one element with B equal to 6
129 query 'A.0.B=6' does no match because index 0 of array A contains B with value 1, but not 6
130 query 'A.B=6&A.C=2' matches because one element of array matches B=6 and other matchesC=2
131 query 'A.ANYINDEX.B=6&A.ANYINDEX.C=2' does not match because it is needed the same element of the
132 array matching both
133
134 Examples of translations from SOL005 to >> mongo # comment
135 A=B; A.eq=B >> A: B # must contain key A and equal to B or be a list that contains B
136 A.cont=B >> A: B
137 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
138 # B or C
139 A.cont=B&A.cont=C; A.cont=B,C >> A: {$in: [B, C]}
140 A.ncont=B >> A: {$nin: B} # must not contain key A or if present not equal to B or if a list,
141 # it must not not contain B
142 A.ncont=B,C; A.ncont=B&A.ncont=C >> A: {$nin: [B,C]} # must not contain key A or if present not equal
143 # neither B nor C; or if a list, it must not contain neither B nor C
144 A.ne=B&A.ne=C; A.ne=B,C >> A: {$nin: [B, C]}
145 A.gt=B >> A: {$gt: B} # must contain key A and greater than B
146 A.ne=B; A.neq=B >> A: {$ne: B} # must not contain key A or if present not equal to B, or if
147 # an array not contain B
148 A.ANYINDEX.B=C >> A: {$elemMatch: {B=C}
tierno6ec13b02018-05-14 11:24:57 +0200149 :return: database mongo filter
150 """
tierno5c012612018-04-19 16:01:59 +0200151 try:
152 db_filter = {}
tierno87858ca2018-10-08 16:30:15 +0200153 if not q_filter:
154 return db_filter
tierno6ec13b02018-05-14 11:24:57 +0200155 for query_k, query_v in q_filter.items():
tierno5c012612018-04-19 16:01:59 +0200156 dot_index = query_k.rfind(".")
157 if dot_index > 1 and query_k[dot_index+1:] in ("eq", "ne", "gt", "gte", "lt", "lte", "cont",
158 "ncont", "neq"):
tiernob20a9022018-05-22 12:07:05 +0200159 operator = "$" + query_k[dot_index + 1:]
tierno5c012612018-04-19 16:01:59 +0200160 if operator == "$neq":
161 operator = "$ne"
162 k = query_k[:dot_index]
163 else:
164 operator = "$eq"
165 k = query_k
166
167 v = query_v
168 if isinstance(v, list):
169 if operator in ("$eq", "$cont"):
170 operator = "$in"
171 v = query_v
172 elif operator in ("$ne", "$ncont"):
173 operator = "$nin"
174 v = query_v
175 else:
176 v = query_v.join(",")
177
178 if operator in ("$eq", "$cont"):
179 # v cannot be a comma separated list, because operator would have been changed to $in
tierno6ec13b02018-05-14 11:24:57 +0200180 db_v = v
tierno5c012612018-04-19 16:01:59 +0200181 elif operator == "$ncount":
182 # v cannot be a comma separated list, because operator would have been changed to $nin
tierno6ec13b02018-05-14 11:24:57 +0200183 db_v = {"$ne": v}
tierno5c012612018-04-19 16:01:59 +0200184 else:
tierno6ec13b02018-05-14 11:24:57 +0200185 db_v = {operator: v}
186
tiernoaf241062018-08-31 14:53:15 +0200187 # process the ANYINDEX word at k.
tierno6ec13b02018-05-14 11:24:57 +0200188 kleft, _, kright = k.rpartition(".ANYINDEX.")
189 while kleft:
190 k = kleft
191 db_v = {"$elemMatch": {kright: db_v}}
192 kleft, _, kright = k.rpartition(".ANYINDEX.")
193
194 # insert in db_filter
195 # maybe db_filter[k] exist. e.g. in the query string for values between 5 and 8: "a.gt=5&a.lt=8"
196 deep_update(db_filter, {k: db_v})
tierno5c012612018-04-19 16:01:59 +0200197
198 return db_filter
199 except Exception as e:
200 raise DbException("Invalid query string filter at {}:{}. Error: {}".format(query_k, v, e),
201 http_code=HTTPStatus.BAD_REQUEST)
202
tierno87858ca2018-10-08 16:30:15 +0200203 def get_list(self, table, q_filter=None):
204 """
205 Obtain a list of entries matching q_filter
206 :param table: collection or table
207 :param q_filter: Filter
208 :return: a list (can be empty) with the found entries. Raises DbException on error
209 """
tierno5c012612018-04-19 16:01:59 +0200210 try:
tiernob20a9022018-05-22 12:07:05 +0200211 result = []
tierno1e9a3292018-11-05 18:18:45 +0100212 with self.lock:
213 collection = self.db[table]
214 db_filter = self._format_filter(q_filter)
215 rows = collection.find(db_filter)
tierno5c012612018-04-19 16:01:59 +0200216 for row in rows:
tiernob20a9022018-05-22 12:07:05 +0200217 result.append(row)
218 return result
tierno5c012612018-04-19 16:01:59 +0200219 except DbException:
220 raise
221 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200222 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200223
delacruzramoae049d82019-09-17 16:05:17 +0200224 def count(self, table, q_filter=None):
225 """
226 Count the number of entries matching q_filter
227 :param table: collection or table
228 :param q_filter: Filter
229 :return: number of entries found (can be zero)
230 :raise: DbException on error
231 """
232 try:
233 with self.lock:
234 collection = self.db[table]
235 db_filter = self._format_filter(q_filter)
236 count = collection.count(db_filter)
237 return count
238 except DbException:
239 raise
240 except Exception as e: # TODO refine
241 raise DbException(e)
242
tierno87858ca2018-10-08 16:30:15 +0200243 def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
244 """
245 Obtain one entry matching q_filter
246 :param table: collection or table
247 :param q_filter: Filter
248 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
249 it raises a DbException
250 :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
251 that it raises a DbException
252 :return: The requested element, or None
253 """
tierno5c012612018-04-19 16:01:59 +0200254 try:
tierno87858ca2018-10-08 16:30:15 +0200255 db_filter = self._format_filter(q_filter)
tierno1e9a3292018-11-05 18:18:45 +0100256 with self.lock:
257 collection = self.db[table]
258 if not (fail_on_empty and fail_on_more):
259 return collection.find_one(db_filter)
260 rows = collection.find(db_filter)
tierno5c012612018-04-19 16:01:59 +0200261 if rows.count() == 0:
262 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200263 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200264 HTTPStatus.NOT_FOUND)
265 return None
266 elif rows.count() > 1:
267 if fail_on_more:
tierno87858ca2018-10-08 16:30:15 +0200268 raise DbException("Found more than one {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200269 HTTPStatus.CONFLICT)
270 return rows[0]
271 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200272 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200273
tierno87858ca2018-10-08 16:30:15 +0200274 def del_list(self, table, q_filter=None):
275 """
276 Deletes all entries that match q_filter
277 :param table: collection or table
278 :param q_filter: Filter
279 :return: Dict with the number of entries deleted
280 """
tierno5c012612018-04-19 16:01:59 +0200281 try:
tierno1e9a3292018-11-05 18:18:45 +0100282 with self.lock:
283 collection = self.db[table]
284 rows = collection.delete_many(self._format_filter(q_filter))
tierno5c012612018-04-19 16:01:59 +0200285 return {"deleted": rows.deleted_count}
286 except DbException:
287 raise
288 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200289 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200290
tierno87858ca2018-10-08 16:30:15 +0200291 def del_one(self, table, q_filter=None, fail_on_empty=True):
292 """
293 Deletes one entry that matches q_filter
294 :param table: collection or table
295 :param q_filter: Filter
296 :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
297 which case it raises a DbException
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_one(self._format_filter(q_filter))
tierno5c012612018-04-19 16:01:59 +0200304 if rows.deleted_count == 0:
305 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200306 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200307 HTTPStatus.NOT_FOUND)
308 return None
309 return {"deleted": rows.deleted_count}
310 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200311 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200312
313 def create(self, table, indata):
tierno87858ca2018-10-08 16:30:15 +0200314 """
315 Add a new entry at database
316 :param table: collection or table
317 :param indata: content to be added
318 :return: database id of the inserted element. Raises a DbException on error
319 """
tierno5c012612018-04-19 16:01:59 +0200320 try:
tierno1e9a3292018-11-05 18:18:45 +0100321 with self.lock:
322 collection = self.db[table]
323 data = collection.insert_one(indata)
tierno5c012612018-04-19 16:01:59 +0200324 return data.inserted_id
325 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200326 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200327
tiernod63ea272018-11-27 12:03:36 +0100328 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 +0200329 """
330 Modifies an entry at database
331 :param table: collection or table
332 :param q_filter: Filter
333 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
334 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
335 it raises a DbException
tiernod63ea272018-11-27 12:03:36 +0100336 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
337 ignored. If not exist, it is ignored
338 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
339 if exist in the array is removed. If not exist, it is ignored
340 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
341 is appended to the end of the array
tierno87858ca2018-10-08 16:30:15 +0200342 :return: Dict with the number of entries modified. None if no matching is found.
343 """
tierno5c012612018-04-19 16:01:59 +0200344 try:
tiernod63ea272018-11-27 12:03:36 +0100345 db_oper = {}
346 if update_dict:
347 db_oper["$set"] = update_dict
348 if unset:
349 db_oper["$unset"] = unset
350 if pull:
351 db_oper["$pull"] = pull
352 if push:
353 db_oper["$push"] = push
354
tierno1e9a3292018-11-05 18:18:45 +0100355 with self.lock:
356 collection = self.db[table]
tiernod63ea272018-11-27 12:03:36 +0100357 rows = collection.update_one(self._format_filter(q_filter), db_oper)
tierno3054f782018-04-25 16:59:53 +0200358 if rows.matched_count == 0:
tierno5c012612018-04-19 16:01:59 +0200359 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200360 raise DbException("Not found any {} with filter='{}'".format(table[:-1], q_filter),
tierno5c012612018-04-19 16:01:59 +0200361 HTTPStatus.NOT_FOUND)
362 return None
tierno3054f782018-04-25 16:59:53 +0200363 return {"modified": rows.modified_count}
tierno5c012612018-04-19 16:01:59 +0200364 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200365 raise DbException(e)
tierno5c012612018-04-19 16:01:59 +0200366
tierno87858ca2018-10-08 16:30:15 +0200367 def set_list(self, table, q_filter, update_dict):
368 """
369 Modifies al matching entries at database
370 :param table: collection or table
371 :param q_filter: Filter
372 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
373 :return: Dict with the number of entries modified
374 """
tierno5c012612018-04-19 16:01:59 +0200375 try:
tierno1e9a3292018-11-05 18:18:45 +0100376 with self.lock:
377 collection = self.db[table]
378 rows = collection.update_many(self._format_filter(q_filter), {"$set": update_dict})
tierno87858ca2018-10-08 16:30:15 +0200379 return {"modified": rows.modified_count}
380 except Exception as e: # TODO refine
381 raise DbException(e)
382
383 def replace(self, table, _id, indata, fail_on_empty=True):
384 """
385 Replace the content of an entry
386 :param table: collection or table
387 :param _id: internal database id
388 :param indata: content to replace
389 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
390 it raises a DbException
391 :return: Dict with the number of entries replaced
392 """
393 try:
394 db_filter = {"_id": _id}
tierno1e9a3292018-11-05 18:18:45 +0100395 with self.lock:
396 collection = self.db[table]
397 rows = collection.replace_one(db_filter, indata)
tierno5c012612018-04-19 16:01:59 +0200398 if rows.matched_count == 0:
399 if fail_on_empty:
tierno87858ca2018-10-08 16:30:15 +0200400 raise DbException("Not found any {} with _id='{}'".format(table[:-1], _id), HTTPStatus.NOT_FOUND)
tierno5c012612018-04-19 16:01:59 +0200401 return None
tierno3054f782018-04-25 16:59:53 +0200402 return {"replaced": rows.modified_count}
tierno5c012612018-04-19 16:01:59 +0200403 except Exception as e: # TODO refine
tierno87858ca2018-10-08 16:30:15 +0200404 raise DbException(e)