diff --git a/report_coverage_common.sh b/report_coverage_common.sh new file mode 100755 index 0000000000000000000000000000000000000000..be7224ad610ddf2c266fa485c6535f165cdc72ca --- /dev/null +++ b/report_coverage_common.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./report_coverage_all.sh | grep --color -E -i "^common/.*$|$" diff --git a/report_coverage_common_orm.sh b/report_coverage_common_orm.sh new file mode 100755 index 0000000000000000000000000000000000000000..b28d4e8aecdba4fce5104af04efb08900362394e --- /dev/null +++ b/report_coverage_common_orm.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./report_coverage_all.sh | grep -v -E "^(cent|comp|cont|devi|moni|serv|test)" | grep --color -E -i "^common/orm/.*$|$" diff --git a/src/common/orm/_Database.py b/src/common/orm/_Database.py index 95d483361ec230294027320e9dd26cf1171a3f4e..c1a0a9065b33adf8309ae5394d7dbf1594abcfbf 100644 --- a/src/common/orm/_Database.py +++ b/src/common/orm/_Database.py @@ -1,7 +1,6 @@ import logging from typing import List, Set from .backend._Backend import _Backend -from .fields.PrimaryKeyField import PrimaryKeyField from .model.Model import Model from .Exceptions import MutexException @@ -26,17 +25,16 @@ class _Database(Model): @property def backend_key(self) -> str: return '' - def __enter__(self) -> '_Database': - self._acquired, self._owner_key = self._backend.lock() - if not self._acquired: raise MutexException('Unable to acquire database lock') - return self + #def __enter__(self) -> '_Database': + # self._acquired, self._owner_key = self._backend.lock() + # if not self._acquired: raise MutexException('Unable to acquire database lock') + # return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self._backend.unlock(self._owner_key) + #def __exit__(self, exc_type, exc_val, exc_tb) -> None: + # self._backend.unlock(self._owner_key) def clear_all(self, keep_keys : Set[str] = set()) -> None: - keys = self._backend.keys() - for key in keys: + for key in self._backend.keys(): if key in keep_keys: continue self._backend.delete(key) diff --git a/src/common/orm/backend/Tools.py b/src/common/orm/backend/Tools.py new file mode 100644 index 0000000000000000000000000000000000000000..bc24bbf363215c2c60a66ba2641d3070623851ca --- /dev/null +++ b/src/common/orm/backend/Tools.py @@ -0,0 +1,5 @@ +from typing import List, Union + +def key_to_str(key : Union[str, List[str]]) -> str: + if isinstance(key, str): return key + return '/'.join(map(str, key)) diff --git a/src/common/orm/backend/_Backend.py b/src/common/orm/backend/_Backend.py index 6d6697bc4a7e2877367f9cfbd815cc4a2e7a18b1..a8dd7eac293e101605c461ef9131deaf72e54507 100644 --- a/src/common/orm/backend/_Backend.py +++ b/src/common/orm/backend/_Backend.py @@ -1,49 +1,49 @@ -from typing import Dict, List, Set, Tuple +from typing import Dict, List, Optional, Tuple class _Backend: def __init__(self, **settings) -> None: raise NotImplementedError() - def lock(self) -> Tuple[bool, str]: + def lock(self, keys : List[List[str]], owner_key : Optional[str] = None) -> Tuple[bool, str]: raise NotImplementedError() - def unlock(self, owner_key : str) -> bool: + def unlock(self, keys : List[List[str]], owner_key : str) -> bool: raise NotImplementedError() def keys(self) -> list: raise NotImplementedError() - def exists(self, key_name : str) -> bool: + def exists(self, key : List[str]) -> bool: raise NotImplementedError() - def delete(self, key_name : str) -> bool: + def delete(self, key : List[str]) -> bool: raise NotImplementedError() - def dict_get(self, key_name : str, fields : List[str] = []) -> Dict[str, str]: + def dict_get(self, key : List[str], fields : List[str] = []) -> Dict[str, str]: raise NotImplementedError() - def dict_update(self, key_name : str, update_fields : Dict[str,str] = {}, remove_fields : Set[str] = set()) -> None: + def dict_update(self, key : List[str], fields : Dict[str,str] = {}) -> None: raise NotImplementedError() - def dict_delete(self, key_name : str, fields : List[str] = []) -> None: + def dict_delete(self, key : List[str], fields : List[str] = []) -> None: raise NotImplementedError() - def list_get_all(self, key_name : str) -> List[str]: + def list_get_all(self, key : List[str]) -> List[str]: raise NotImplementedError() - def list_push_last(self, key_name : str, item : str) -> None: + def list_push_last(self, key : List[str], item : str) -> None: raise NotImplementedError() - def list_remove_first_occurrence(self, key_name : str, item: str) -> None: + def list_remove_first_occurrence(self, key : List[str], item: str) -> None: raise NotImplementedError() - def set_add(self, key_name : str, item : str) -> None: + def set_add(self, key : List[str], item : str) -> None: raise NotImplementedError() - def set_has(self, key_name : str, item : str) -> bool: + def set_has(self, key : List[str], item : str) -> bool: raise NotImplementedError() - def set_remove(self, key_name : str, item : str) -> None: + def set_remove(self, key : List[str], item : str) -> None: raise NotImplementedError() def dump(self) -> List[str]: diff --git a/src/common/orm/backend/inmemory/InMemoryBackend.py b/src/common/orm/backend/inmemory/InMemoryBackend.py index ee141600a508ee4e5ef4ea351e9c9d3541500133..13a38027555799e23cabaf32c3d8ef55d826ef15 100644 --- a/src/common/orm/backend/inmemory/InMemoryBackend.py +++ b/src/common/orm/backend/inmemory/InMemoryBackend.py @@ -1,99 +1,135 @@ +# InMemeory Database Backend +# -------------------------- +# - Concurrency is limited to 1 operation at a time +# - All operations are strictly sequential by means of locks +# - WARNING: DESIGNED AND BUILT FOR UNIT TESTING AND INTEGRATION TESTING PURPOSES ONLY !!! +# USE ANOTHER BACKEND IN PRODUCTION ENVIRONMENTS. + import copy, logging, threading, uuid -from typing import Dict, List, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple, Union from .._Backend import _Backend +from ..Tools import key_to_str from .Tools import get_or_create_dict, get_or_create_list, get_or_create_set LOGGER = logging.getLogger(__name__) class InMemoryBackend(_Backend): def __init__(self, **settings): - self._internal_lock = threading.Lock() - self._external_lock = threading.Lock() - self._owner_key = None - self._keys = {} # name => set/list/dict/string - - def lock(self) -> Tuple[bool, str]: - owner_key = str(uuid.uuid4()) - with self._internal_lock: - acquired = self._external_lock.acquire() - if not acquired: return False, None - self._owner_key = owner_key + self._lock = threading.Lock() + self._keys : Dict[str, Union[Set[str], List[str], Dict[str, str], str]]= {} # key => set/list/dict/string + + def lock(self, keys : List[List[str]], owner_key : Optional[str] = None) -> Tuple[bool, str]: + # InMemoryBackend uses a database where all operations are atomic. Locks are implemented by assigning the lock + # owner key into a string variable. If the field is empty and enables to + owner_key = str(uuid.uuid4()) if owner_key is None else owner_key + str_keys = {key_to_str(key) for key in keys} + with self._lock: + acquired_lock_keys : Dict[str, str] = {} + for str_key in str_keys: + if (str_key in self._keys) and (len(self._keys[str_key]) > 0) and (self._keys[str_key] != owner_key): + # lock already acquired, cannot acquire all locks atomically + for str_key in acquired_lock_keys.keys(): + if str_key not in self._keys: continue + del self._keys[str_key] + return False, None + else: + # lock available, temporarily acquire it; locks will be released if some of them for a requested + # key is not available + self._keys[str_key] = owner_key + acquired_lock_keys[str_key] = owner_key return True, owner_key - def unlock(self, owner_key : str) -> bool: - with self._internal_lock: - if self._owner_key != owner_key: return False - self._external_lock.release() - self._owner_key = None + def unlock(self, keys : List[List[str]], owner_key : str) -> bool: + str_keys = {key_to_str(key) for key in keys} + with self._lock: + for str_key in str_keys: + if str_key not in self._keys: return False + if self._keys[str_key] != owner_key: return False + # Up to here, we own all the keys we want to release + for str_key in str_keys: + del self._keys[str_key] return True def keys(self) -> list: - with self._internal_lock: + with self._lock: return copy.deepcopy(list(self._keys.keys())) - def exists(self, key_name : str) -> bool: - with self._internal_lock: - return key_name in self._keys + def exists(self, key : List[str]) -> bool: + str_key = key_to_str(key) + with self._lock: + return str_key in self._keys - def delete(self, key_name : str) -> bool: - with self._internal_lock: - if key_name not in self._keys: return False - del self._keys[key_name] + def delete(self, key : List[str]) -> bool: + str_key = key_to_str(key) + with self._lock: + if str_key not in self._keys: return False + del self._keys[str_key] return True - def dict_get(self, key_name : str, fields : List[str] = []) -> Dict[str, str]: - with self._internal_lock: - container = get_or_create_dict(self._keys, key_name) + def dict_get(self, key : List[str], fields : List[str] = []) -> Dict[str, str]: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_dict(self._keys, str_key) if len(fields) == 0: fields = container.keys() return copy.deepcopy({ field_name : field_value for field_name,field_value in container.items() if field_name in fields }) - def dict_update(self, key_name : str, update_fields : Dict[str,str] = {}, remove_fields : Set[str] = set()) -> None: - with self._internal_lock: - container = get_or_create_dict(self._keys, key_name) - for field in list(remove_fields): container.pop(field, None) - container.update(update_fields) - - def dict_delete(self, key_name : str, fields : List[str] = []) -> None: - with self._internal_lock: - container = get_or_create_dict(self._keys, key_name) - if len(fields) == 0: fields = container.keys() - for field in list(fields): container.pop(field, None) - - def list_get_all(self, key_name : str) -> List[str]: - with self._internal_lock: - container = get_or_create_list(self._keys, key_name) + def dict_update(self, key : List[str], fields : Dict[str,str] = {}) -> None: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_dict(self._keys, str_key) + container.update(fields) + + def dict_delete(self, key : List[str], fields : List[str] = []) -> None: + str_key = key_to_str(key) + with self._lock: + if len(fields) == 0: + if str_key not in self._keys: return False + del self._keys[str_key] + else: + container = get_or_create_dict(self._keys, str_key) + for field in list(fields): container.pop(field, None) + + def list_get_all(self, key : List[str]) -> List[str]: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_list(self._keys, str_key) return copy.deepcopy(container) - def list_push_last(self, key_name : str, item : str) -> None: - with self._internal_lock: - container = get_or_create_list(self._keys, key_name) + def list_push_last(self, key : List[str], item : str) -> None: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_list(self._keys, str_key) container.append(item) - def list_remove_first_occurrence(self, key_name : str, item: str) -> None: - with self._internal_lock: - container = get_or_create_list(self._keys, key_name) + def list_remove_first_occurrence(self, key : List[str], item: str) -> None: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_list(self._keys, str_key) container.remove(item) - def set_add(self, key_name : str, item : str) -> None: - with self._internal_lock: - container = get_or_create_set(self._keys, key_name) + def set_add(self, key : List[str], item : str) -> None: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_set(self._keys, str_key) container.add(item) - def set_has(self, key_name : str, item : str) -> bool: - with self._internal_lock: - container = get_or_create_set(self._keys, key_name) + def set_has(self, key : List[str], item : str) -> bool: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_set(self._keys, str_key) return item in container - def set_remove(self, key_name : str, item : str) -> None: - with self._internal_lock: - container = get_or_create_set(self._keys, key_name) + def set_remove(self, key : List[str], item : str) -> None: + str_key = key_to_str(key) + with self._lock: + container = get_or_create_set(self._keys, str_key) container.discard(item) def dump(self) -> List[Tuple[str, str, str]]: - with self._internal_lock: + with self._lock: entries = [] - for key_name,key_value in self._keys.items(): - entries.append((key_name, type(key_value).__name__, str(key_value))) + for str_key,key_value in self._keys.items(): + entries.append((str_key, type(key_value).__name__, str(key_value))) return entries diff --git a/src/common/orm/backend/inmemory/Tools.py b/src/common/orm/backend/inmemory/Tools.py index 7eb18c94732915bb35e5bdd4087f79647028c600..f8fb573504bc6daa9622c79482fc0676a31058f2 100644 --- a/src/common/orm/backend/inmemory/Tools.py +++ b/src/common/orm/backend/inmemory/Tools.py @@ -1,19 +1,22 @@ from typing import Dict, List, Set, Union -def get_or_create_dict(keys : Dict[str, Union[Dict, List, Set]], key_name : str) -> Dict: - container = keys.get(key_name, None) - if container is None: container = keys.setdefault(key_name, dict()) - if not isinstance(container, dict): raise Exception('Key({}) is not a dict'.format(key_name)) +def get_or_create_dict(keys : Dict[str, Union[Dict, List, Set]], str_key : str) -> Dict: + container = keys.get(str_key, None) + if container is None: container = keys.setdefault(str_key, dict()) + if not isinstance(container, dict): + raise Exception('Key({:s}, {:s}) is not a dict'.format(str(type(container).__name__), str(str_key))) return container -def get_or_create_list(keys : Dict[str, Union[Dict, List, Set]], key_name : str) -> List: - container = keys.get(key_name, None) - if container is None: container = keys.setdefault(key_name, list()) - if not isinstance(container, list): raise Exception('Key({}) is not a list'.format(key_name)) +def get_or_create_list(keys : Dict[str, Union[Dict, List, Set]], str_key : str) -> List: + container = keys.get(str_key, None) + if container is None: container = keys.setdefault(str_key, list()) + if not isinstance(container, list): + raise Exception('Key({:s}, {:s}) is not a list'.format(str(type(container).__name__), str(str_key))) return container -def get_or_create_set(keys : Dict[str, Union[Dict, List, Set]], key_name : str) -> Set: - container = keys.get(key_name, None) - if container is None: container = keys.setdefault(key_name, set()) - if not isinstance(container, set): raise Exception('Key({}) is not a set'.format(key_name)) +def get_or_create_set(keys : Dict[str, Union[Dict, List, Set]], str_key : str) -> Set: + container = keys.get(str_key, None) + if container is None: container = keys.setdefault(str_key, set()) + if not isinstance(container, set): + raise Exception('Key({:s}, {:s}) is not a set'.format(str(type(container).__name__), str(str_key))) return container diff --git a/src/common/orm/backend/redis/RedisBackend.py b/src/common/orm/backend/redis/RedisBackend.py index b18cd54b60aa1e1282a72587241733fe3f28e9cc..028c735540950d2f2c58145f595be278934450b7 100644 --- a/src/common/orm/backend/redis/RedisBackend.py +++ b/src/common/orm/backend/redis/RedisBackend.py @@ -1,15 +1,14 @@ import os, uuid -from typing import Any, Dict, List, Set, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from redis.client import Redis from .._Backend import _Backend +from ..Tools import key_to_str from .Mutex import Mutex DEFAULT_SERVICE_HOST = '127.0.0.1' DEFAULT_SERVICE_PORT = 6379 DEFAULT_DATABASE_ID = 0 -KEY_ENTIRE_DATABASE_LOCK = 'everything' - def get_setting(settings : Dict[str, Any], name : str, default : Any) -> Any: value = settings.get(name, os.environ.get(name)) return default if value is None else value @@ -22,46 +21,57 @@ class RedisBackend(_Backend): self._client = Redis.from_url('redis://{host}:{port}/{dbid}'.format(host=host, port=port, dbid=dbid)) self._mutex = Mutex(self._client) - def lock(self) -> Tuple[bool, str]: - owner_key = str(uuid.uuid4()) - return self._mutex.acquire(KEY_ENTIRE_DATABASE_LOCK, owner_key=owner_key, blocking=True) + def lock(self, keys : List[List[str]], owner_key : Optional[str] = None) -> Tuple[bool, str]: + str_keys = {key_to_str(key) for key in keys} + owner_key = str(uuid.uuid4()) if owner_key is None else owner_key + return self._mutex.acquire(str_keys, owner_key=owner_key, blocking=True) - def unlock(self, owner_key : str) -> bool: - return self._mutex.release(KEY_ENTIRE_DATABASE_LOCK, owner_key) + def unlock(self, keys : List[List[str]], owner_key : str) -> bool: + str_keys = {key_to_str(key) for key in keys} + return self._mutex.release(str_keys, owner_key) def keys(self) -> list: return [k.decode('UTF-8') for k in self._client.keys()] - def exists(self, key_name : str) -> bool: - return self._client.exists(key_name) == 1 + def exists(self, key : List[str]) -> bool: + str_key = key_to_str(key) + return self._client.exists(str_key) == 1 - def delete(self, key_name : str) -> bool: - return self._client.delete(key_name) == 1 + def delete(self, key : List[str]) -> bool: + str_key = key_to_str(key) + return self._client.delete(str_key) == 1 - def set_has(self, key_name : str, item : str) -> bool: - return self._client.sismember(key_name, item) == 1 + def set_has(self, key : List[str], item : str) -> bool: + str_key = key_to_str(key) + return self._client.sismember(str_key, item) == 1 - def set_add(self, key_name : str, item : str) -> None: - self._client.sadd(key_name, item) + def set_add(self, key : List[str], item : str) -> None: + str_key = key_to_str(key) + self._client.sadd(str_key, item) - def set_remove(self, key_name : str, item : str) -> None: - self._client.srem(key_name, item) + def set_remove(self, key : List[str], item : str) -> None: + str_key = key_to_str(key) + self._client.srem(str_key, item) - def list_push_last(self, key_name : str, item : str) -> None: - self._client.rpush(key_name, item) + def list_push_last(self, key : List[str], item : str) -> None: + str_key = key_to_str(key) + self._client.rpush(str_key, item) - def list_get_all(self, key_name : str) -> List[str]: - return list(map(lambda m: m.decode('UTF-8'), self._client.lrange(key_name, 0, -1))) + def list_get_all(self, key : List[str]) -> List[str]: + str_key = key_to_str(key) + return list(map(lambda m: m.decode('UTF-8'), self._client.lrange(str_key, 0, -1))) - def list_remove_first_occurrence(self, key_name : str, item: str) -> None: - self._client.lrem(key_name, 1, item) + def list_remove_first_occurrence(self, key : List[str], item: str) -> None: + str_key = key_to_str(key) + self._client.lrem(str_key, 1, item) - def dict_get(self, key_name : str, fields : List[str] = []) -> Dict[str, str]: + def dict_get(self, key : List[str], fields : List[str] = []) -> Dict[str, str]: + str_key = key_to_str(key) if len(fields) == 0: - keys_values = self._client.hgetall(key_name).items() + keys_values = self._client.hgetall(str_key).items() else: fields = list(fields) - keys_values = zip(fields, self._client.hmget(key_name, fields)) + keys_values = zip(fields, self._client.hmget(str_key, fields)) attributes = {} for key,value in keys_values: @@ -69,25 +79,23 @@ class RedisBackend(_Backend): attributes[str_key] = value.decode('UTF-8') if isinstance(value, bytes) else value return attributes - def dict_update( - self, key_name : str, update_fields : Dict[str, str] = {}, remove_fields : Set[str] = set()) -> None: - if len(remove_fields) > 0: - self._client.hdel(key_name, *remove_fields) - - if len(update_fields) > 0: - self._client.hset(key_name, mapping=update_fields) + def dict_update(self, key : List[str], fields : Dict[str, str] = {}) -> None: + str_key = key_to_str(key) + if len(fields) > 0: + self._client.hset(str_key, mapping=fields) - def dict_delete(self, key_name : str, fields : List[str] = []) -> None: + def dict_delete(self, key : List[str], fields : List[str] = []) -> None: + str_key = key_to_str(key) if len(fields) == 0: - self._client.delete(key_name) + self._client.delete(str_key) else: - self._client.hdel(key_name, set(fields)) + self._client.hdel(str_key, set(fields)) def dump(self) -> List[Tuple[str, str, str]]: entries = [] - for key_name in self._client.keys(): - key_name = key_name.decode('UTF-8') - key_type = self._client.type(key_name) + for str_key in self._client.keys(): + str_key = str_key.decode('UTF-8') + key_type = self._client.type(str_key) if key_type is not None: key_type = key_type.decode('UTF-8') key_type = { 'hash' : 'dict', @@ -101,5 +109,5 @@ class RedisBackend(_Backend): 'set' : lambda key: {m.decode('UTF-8') for m in self._client.smembers(key)}, 'str' : lambda key: self._client.get(key).decode('UTF-8'), }.get(key_type, lambda key: 'UNSUPPORTED_TYPE') - entries.append((key_name, key_type, key_content(key_name))) + entries.append((str_key, key_type, key_content(str_key))) return entries diff --git a/src/common/orm/fields/BooleanField.py b/src/common/orm/fields/BooleanField.py index 2eadaf5f3034cb11d2791b045cfb4fe7a1d9faab..e802b4e4bf1ed6b69b57a2d84b47010618f63bd7 100644 --- a/src/common/orm/fields/BooleanField.py +++ b/src/common/orm/fields/BooleanField.py @@ -1,12 +1,24 @@ -from typing import Any +from __future__ import annotations +from typing import TYPE_CHECKING from common.type_checkers.Checkers import chk_boolean, chk_not_none from .Field import Field +if TYPE_CHECKING: + from ..model.Model import Model + +BOOL_TRUE_VALUES = {'TRUE', 'T', '1'} + class BooleanField(Field): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, type_=bool, **kwargs) - def __set__(self, instance, value : bool) -> None: + def __set__(self, instance : 'Model', value : bool) -> None: if self.required: chk_not_none(self.name, value) if value is None: super().__set__(instance, value) super().__set__(instance, chk_boolean(self.name, value)) + + def serialize(self, value : bool) -> str: + return str(value) + + def deserialize(self, value : str) -> bool: + return (value.upper() in BOOL_TRUE_VALUES) diff --git a/src/common/orm/fields/Field.py b/src/common/orm/fields/Field.py index e10938c306dfd4f3934cacaf32654519e9c289e1..80ba0a8c8229ce6c68335e1e63627c808f9ec364 100644 --- a/src/common/orm/fields/Field.py +++ b/src/common/orm/fields/Field.py @@ -1,6 +1,10 @@ -from typing import Any, List, Set, Tuple, Union +from __future__ import annotations +from typing import TYPE_CHECKING, Any, List, Set, Tuple, Union from common.type_checkers.Checkers import chk_boolean, chk_string, chk_type +if TYPE_CHECKING: + from ..model.Model import Model + class Field: def __init__( self, name : str = None, type_ : Union[type, Set[type], Tuple[type], List[type]] = object, @@ -9,8 +13,14 @@ class Field: self.type_ = chk_type('Field.type', type_, (type, set, tuple, list)) self.required = chk_boolean('Field.required', required) - def __set__(self, instance, value : Any): + def __set__(self, instance : 'Model', value : Any) -> None: instance.__dict__[self.name] = value - def __delete__(self, instance): + def __delete__(self, instance : 'Model'): raise AttributeError('Attribute "{:s}" cannot be deleted'.format(self.name)) + + def serialize(self, value : Any) -> str: + raise NotImplementedError + + def deserialize(self, value : str) -> Any: + raise NotImplementedError diff --git a/src/common/orm/fields/FloatField.py b/src/common/orm/fields/FloatField.py index f53cd25136ddb5326c7092a617110ca3007b7012..b1485b33f07d71dd684aa0b1b1dd87a2e3ec7058 100644 --- a/src/common/orm/fields/FloatField.py +++ b/src/common/orm/fields/FloatField.py @@ -1,7 +1,11 @@ -from typing import Optional +from __future__ import annotations +from typing import TYPE_CHECKING, Optional from common.type_checkers.Checkers import chk_float, chk_not_none from .Field import Field +if TYPE_CHECKING: + from ..model.Model import Model + class FloatField(Field): def __init__( self, *args, min_value : Optional[float] = None, max_value : Optional[float] = None, **kwargs) -> None: @@ -12,7 +16,13 @@ class FloatField(Field): self._max_value = None if max_value is None else \ chk_float('{}.max_value'.format(self.name), max_value, min_value=self._min_value) - def __set__(self, instance, value : float) -> None: + def __set__(self, instance : 'Model', value : float) -> None: if self.required: chk_not_none(self.name, value) if value is None: super().__set__(instance, value) super().__set__(instance, chk_float(self.name, value, min_value=self._min_value, max_value=self._max_value)) + + def serialize(self, value : float) -> str: + return str(value) + + def deserialize(self, value : str) -> float: + return float(value) diff --git a/src/common/orm/fields/ForeignKeyField.py b/src/common/orm/fields/ForeignKeyField.py index f6e7fd394a6a0bd548238d017ba95b8fbed05eda..ac34403d5bd7e6edef98a160e0c2d1d1b71dcb2b 100644 --- a/src/common/orm/fields/ForeignKeyField.py +++ b/src/common/orm/fields/ForeignKeyField.py @@ -1,16 +1,20 @@ -from typing import Optional -from .Field import Field +from __future__ import annotations +from typing import TYPE_CHECKING +from common.type_checkers.Checkers import chk_issubclass, chk_not_none, chk_type +from ..backend.Tools import key_to_str +from .StringField import StringField -class ForeignKeyField(Field): - def __init__(self, *args, foreign_model : 'Model' = None, **kwargs) -> None: - self.foreign_model = foreign_model - super().__init__(*args, type_=(float, int), **kwargs) +if TYPE_CHECKING: + from ..model.Model import Model - def __set__(self, instance, value): - if self.min_value is not None and value < self.min_value: - raise ValueError('Attribute "{:s}" expects value >= {:s}; requested({:s})'.format( - str(self.name), str(self.min_value), str(value))) - if self.max_value is not None and value > self.max_value: - raise ValueError('Attribute "{:s}" expects value <= {:s}; requested({:s})'.format( - str(self.name), str(self.max_value), str(value))) - super().__set__(instance, value) +class ForeignKeyField(StringField): + def __init__(self, foreign_model : 'Model', *args, required : bool = True, **kwargs) -> None: + from ..model.Model import Model + self.foreign_model : Model = chk_issubclass('Field.foreign_model', foreign_model, Model) + super().__init__(*args, required=required, allow_empty=not required, **kwargs) + + def __set__(self, instance : 'Model', value : 'Model') -> None: + if self.required: chk_not_none(self.name, value) + if value is None: super().__set__(instance, value) + model_instance : 'Model' = chk_type('value', value, self.foreign_model) + super().__set__(instance, key_to_str(model_instance.backend_key)) diff --git a/src/common/orm/fields/IntegerField.py b/src/common/orm/fields/IntegerField.py index 5622af9755a824689fa3b002d2a65dd84bb3d9e4..72cccdac7fd7bad734e9503434764be7c9e797ea 100644 --- a/src/common/orm/fields/IntegerField.py +++ b/src/common/orm/fields/IntegerField.py @@ -1,7 +1,11 @@ -from typing import Optional +from __future__ import annotations +from typing import TYPE_CHECKING, Optional from common.type_checkers.Checkers import chk_integer, chk_not_none from .Field import Field +if TYPE_CHECKING: + from ..model.Model import Model + class IntegerField(Field): def __init__( self, *args, min_value : Optional[int] = None, max_value : Optional[int] = None, **kwargs) -> None: @@ -12,7 +16,13 @@ class IntegerField(Field): self._max_value = None if max_value is None else \ chk_integer('{}.max_value'.format(self.name), max_value, min_value=self._min_value) - def __set__(self, instance, value : int) -> None: + def __set__(self, instance : 'Model', value : int) -> None: if self.required: chk_not_none(self.name, value) if value is None: super().__set__(instance, value) super().__set__(instance, chk_integer(self.name, value, min_value=self._min_value, max_value=self._max_value)) + + def serialize(self, value : int) -> str: + return str(value) + + def deserialize(self, value : str) -> int: + return int(value) diff --git a/src/common/orm/fields/PrimaryKeyField.py b/src/common/orm/fields/PrimaryKeyField.py index 9f0cdc50151f595afd6ad30f6611077f028e3ac0..2111c1b65e9af6f7899a0ee9254ce103d3bb3882 100644 --- a/src/common/orm/fields/PrimaryKeyField.py +++ b/src/common/orm/fields/PrimaryKeyField.py @@ -1,13 +1,17 @@ -from typing import Any +from __future__ import annotations +from typing import TYPE_CHECKING from common.type_checkers.Checkers import chk_not_none from .StringField import StringField +if TYPE_CHECKING: + from ..model.Model import Model + class PrimaryKeyField(StringField): def __init__(self, *args, required : bool = True, **kwargs) -> None: super().__init__(*args, required=True, allow_empty=False, min_length=1, **kwargs) - def __set__(self, instance, value : str): + def __set__(self, instance : 'Model', value : str) -> None: chk_not_none(self.name, value) # Always required if (self.name in instance.__dict__) and (instance.__dict__[self.name] is not None): raise ValueError('PrimaryKeyField cannot be modified') - return super().__set__(instance, value) + super().__set__(instance, value) diff --git a/src/common/orm/fields/StringField.py b/src/common/orm/fields/StringField.py index ca5a5a9ccfcf258de1c9f7ce1d10e25dcddaee48..8a6e260a116aab29c140ff377b7a033248c4dcaa 100644 --- a/src/common/orm/fields/StringField.py +++ b/src/common/orm/fields/StringField.py @@ -1,8 +1,12 @@ +from __future__ import annotations import re -from typing import Optional, Pattern, Union +from typing import TYPE_CHECKING, Optional, Pattern, Union from common.type_checkers.Checkers import chk_boolean, chk_integer, chk_not_none, chk_string from .Field import Field +if TYPE_CHECKING: + from ..model.Model import Model + class StringField(Field): def __init__( self, *args, allow_empty : bool = False, min_length : Optional[int] = None, max_length : Optional[int] = None, @@ -16,9 +20,15 @@ class StringField(Field): chk_integer('{}.max_length'.format(self.name), max_length, min_value=self._min_length) self._pattern = None if pattern is None else re.compile(pattern) - def __set__(self, instance, value : str) -> None: + def __set__(self, instance : 'Model', value : str) -> None: if self.required: chk_not_none(self.name, value) if value is None: super().__set__(instance, value) super().__set__(instance, chk_string( self.name, value, allow_empty=self._allow_empty, min_length=self._min_length, max_length=self._max_length, pattern=self._pattern)) + + def serialize(self, value : str) -> str: + return value + + def deserialize(self, value : str) -> str: + return value diff --git a/src/common/orm/fields/__init__.py b/src/common/orm/fields/__init__.py index b145d2b87ef70d0f2c8770a8fe07b115ca5d75c6..b97f0f08a646c23d51b98b03bf7fe29f9c7d8bb8 100644 --- a/src/common/orm/fields/__init__.py +++ b/src/common/orm/fields/__init__.py @@ -1,5 +1,8 @@ +from .BooleanField import BooleanField from .Field import Field from .FloatField import FloatField +from .ForeignKeyField import ForeignKeyField from .IntegerField import IntegerField +from .PrimaryKeyField import PrimaryKeyField from .StringField import StringField -__all__ = ['Field', 'FloatField', 'IntegerField', 'StringField'] +__all__ = ['BooleanField', 'Field', 'FloatField', 'ForeignKeyField', 'IntegerField', 'PrimaryKeyField', 'StringField'] diff --git a/src/common/orm/model/Model.py b/src/common/orm/model/Model.py index 0124d163c66b6cac563fd13c6fb95eefe30ff7b1..b4950d163208aec8981be13d56c3c6011d47c7c5 100644 --- a/src/common/orm/model/Model.py +++ b/src/common/orm/model/Model.py @@ -1,9 +1,12 @@ from __future__ import annotations -from typing import Any, Dict, Mapping, Tuple -from common.orm.fields.PrimaryKeyField import PrimaryKeyField +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Type +from common.orm.backend.Tools import key_to_str +from common.orm.fields.ForeignKeyField import ForeignKeyField from common.type_checkers.Checkers import chk_none +from ..Exceptions import MutexException from ..backend._Backend import _Backend from ..fields.Field import Field +from ..fields.PrimaryKeyField import PrimaryKeyField from .Tools import NoDupOrderedDict class MetaModel(type): @@ -18,12 +21,12 @@ class MetaModel(type): if not isinstance(value, Field): continue attrs[key].name = key field_names.append(key) - if isinstance(value, PrimaryKeyField): - if primary_key_field is None: - primary_key_field = value - continue - raise AttributeError('PrimaryKey for Model({:s}) already set to attribute({:s})'.format( - str(name), str(primary_key_field.name))) + if not isinstance(value, PrimaryKeyField): continue + if primary_key_field is None: + primary_key_field = value + continue + raise AttributeError('PrimaryKey for Model({:s}) already set to attribute({:s})'.format( + str(name), str(primary_key_field.name))) cls_obj = super().__new__(metacls, name, bases, dict(attrs)) setattr(cls_obj, '_primary_key_field', primary_key_field) setattr(cls_obj, '_field_names_list', field_names) @@ -38,18 +41,21 @@ class Model(metaclass=MetaModel): self._parent = parent self._backend = self._parent.backend self._class_name = type(self).__name__ - self._backend_key = '{:s}/{:s}'.format(self.parent.backend_key, self._class_name) + backend_key = self._class_name if self._primary_key_field is not None: # pylint: disable=no-member primary_key_field_name = self._primary_key_field.name # pylint: disable=no-member - print('primary_key_field_name', primary_key_field_name) setattr(self, primary_key_field_name, primary_key) - self._backend_key += '[{:s}]'.format(getattr(self, primary_key_field_name)) + backend_key += '[{:s}]'.format(getattr(self, primary_key_field_name)) else: try: chk_none('primary_key', primary_key) except: msg = 'Unable to set primary_key({:s}) since no PrimaryKeyField is defined in the model' raise AttributeError(msg.format(str(primary_key))) + self._backend_key : str = key_to_str([self.parent.backend_key, backend_key]) + self._backend_parent_key : str = self.parent.backend_key + self._backend_references_key : str = key_to_str([self.parent.backend_key, backend_key, 'references']) + self._owner_key : Optional[str] = None @property def parent(self) -> 'Model': return self._parent @@ -60,21 +66,100 @@ class Model(metaclass=MetaModel): @property def backend_key(self) -> str: return self._backend_key + def lock(self, extra_keys : List[List[str]] = []): + lock_keys = [self._backend_key, self.parent.backend_key, self._backend_references_key] + extra_keys + lock_keys = [key_to_str([lock_key, 'lock']) for lock_key in lock_keys] + acquired,self._owner_key = self._backend.lock(lock_keys, owner_key=self._owner_key) + if acquired: return + raise MutexException('Unable to lock keys {:s} using owner_key {:s}'.format( + str(lock_keys), str(self._owner_key))) + + def unlock(self, extra_keys : List[List[str]] = []): + lock_keys = [self._backend_key, self.parent.backend_key, self._backend_references_key] + extra_keys + lock_keys = [key_to_str([lock_key, 'lock']) for lock_key in lock_keys] + released = self._backend.unlock(lock_keys, self._owner_key) + if released: return + raise Exception('Unable to unlock keys {:s} using owner_key {:s}'.format( + str(lock_keys), str(self._owner_key))) + def load(self) -> None: - attributes = self._backend.dict_get(self._backend_key).items() - for name in self._field_names_list: # pylint: disable=no-member - if name not in attributes: continue - setattr(self, name, attributes[name]) + self.lock() + + model_class = type(self) + attributes = self._backend.dict_get(self._backend_key) + pk_field = self._primary_key_field # pylint: disable=no-member + pk_field_name = None if pk_field is None else pk_field.name + for field_name in self._field_names_list: # pylint: disable=no-member + if field_name == pk_field_name: continue + if field_name not in attributes: continue + raw_field_value = attributes[field_name] + field_class : 'Field' = getattr(model_class, field_name) + field_value = field_class.deserialize(raw_field_value) + setattr(self, field_name, field_value) + + self.unlock() def save(self) -> None: - attributes : Dict[str, Any] = { - name:repr(getattr(self, name)) - for name in self._field_names_list # pylint: disable=no-member - } - self._backend.dict_update(self._backend_key, attributes) + model_class = type(self) + + attributes : Dict[str, Any] = dict() + required_keys : Set[str] = set() + foreign_additions : Dict[str, str] = dict() + foreign_removals : Dict[str, str] = dict() + for field_name in self._field_names_list: # pylint: disable=no-member + field_value = str(getattr(self, field_name)) + field_instance : 'Field' = getattr(model_class, field_name) + serialized_field_value = field_instance.serialize(field_value) + if isinstance(field_instance, ForeignKeyField): + foreign_reference = '{:s}:{:s}'.format(self._backend_key, field_name) + field_value_stored = getattr(self, field_name + '_stored', None) + if field_value_stored is not None: + foreign_removals[key_to_str([field_value_stored, 'references'])] = foreign_reference + foreign_additions[key_to_str([serialized_field_value, 'references'])] = foreign_reference + required_keys.add(serialized_field_value) + attributes[field_name] = serialized_field_value + + if len(self._backend_parent_key) > 0: + required_keys.add(self._backend_parent_key) + + extra_keys = [] + extra_keys.extend(list(foreign_removals.keys())) + extra_keys.extend(list(foreign_additions.keys())) + + print('self._backend_key', self._backend_key) + print('extra_keys', extra_keys) + print('attributes', attributes) + print('foreign_removals', foreign_removals) + print('foreign_additions', foreign_additions) + + try: + print('dump_before', self._backend.dump()) + self.lock(extra_keys=extra_keys) + print('dump_after', self._backend.dump()) + + not_exists = [] + for required_key in required_keys: + if self._backend.exists(required_key): continue + not_exists.append('{:s}'.format(str(required_key))) + if len(not_exists) > 0: + raise ValueError('Required Keys ({:s}) does not exist'.format(', '.join(sorted(not_exists)))) + + self._backend.dict_update(self._backend_key, attributes) + for serialized_field_value,foreign_reference in foreign_removals.items(): + self._backend.set_remove(serialized_field_value, foreign_reference) + + for serialized_field_value,foreign_reference in foreign_additions.items(): + self._backend.set_add(serialized_field_value, foreign_reference) + finally: + self.unlock(extra_keys=extra_keys) + + for serialized_field_value,foreign_reference in foreign_additions.items(): + setattr(self, (foreign_reference.split(':')[-1]) + '_stored', field_value_stored) def delete(self) -> None: - self._backend.dict_delete(self._backend_key) + self.lock() + self._backend.delete(self._backend_key) + self.unlock() def dump_id(self) -> Dict: raise NotImplementedError() diff --git a/src/common/orm/tests/test_unitary.py b/src/common/orm/tests/test_unitary.py deleted file mode 100644 index fb2525f9e69023a18cb542ef4d2e822320739498..0000000000000000000000000000000000000000 --- a/src/common/orm/tests/test_unitary.py +++ /dev/null @@ -1,102 +0,0 @@ -import logging, pytest -from common.orm._Database import _Database -from common.orm.backend._Backend import _Backend -from common.orm.backend.inmemory.InMemoryBackend import InMemoryBackend -from common.orm.fields.FloatField import FloatField -from common.orm.fields.IntegerField import IntegerField -from common.orm.fields.PrimaryKeyField import PrimaryKeyField -from common.orm.fields.StringField import StringField -from common.orm.model.Model import Model - -logging.basicConfig(level=logging.INFO) - -def test_database_instantiation(): - with pytest.raises(AttributeError) as e: - _Database(None) - str_class_path = '{}.{}'.format(_Backend.__module__, _Backend.__name__) - assert str(e.value) == 'backend must inherit from {}'.format(str_class_path) - - assert _Database(InMemoryBackend()) is not None - -def test_model_without_attributes(): - with pytest.raises(AttributeError) as e: - Model(None, 'valid-uuid') - str_class_path = '{}.{}'.format(Model.__module__, Model.__name__) - assert str(e.value) == 'parent must inherit from {}'.format(str_class_path) - - database = _Database(InMemoryBackend()) - - with pytest.raises(AttributeError) as e: - Model(database, '') - assert str(e.value) == 'Unable to set primary_key() since no PrimaryKeyField is defined in the model' - - with pytest.raises(AttributeError) as e: - Model(database, 'primary-key') - assert str(e.value) == 'Unable to set primary_key(primary-key) since no PrimaryKeyField is defined in the model' - -def test_model_with_primarykey(): - database = _Database(InMemoryBackend()) - - class TestModel(Model): - pk = PrimaryKeyField() - name = StringField(min_length=1) - age = IntegerField(min_value=0) - salary = FloatField(min_value=0.0) - - with pytest.raises(ValueError) as e: - TestModel(database) - assert str(e.value) == 'pk(None) is None.' - - with pytest.raises(ValueError) as e: - TestModel(database, '') - assert str(e.value) == 'pk() is out of range: allow_empty(False).' - - obj = TestModel(database, 'valid-pk') - assert obj is not None - -def test_model_with_primarykey_and_attributes(): - database = _Database(InMemoryBackend()) - - class TestModel(Model): - pk = PrimaryKeyField() - name = StringField(min_length=1) - age = IntegerField(min_value=0) - salary = FloatField(min_value=0.0) - - with pytest.raises(AttributeError) as e: - Attributes(obj, None, {}, {}) - assert str(e.value) == 'key_pattern must be a non-empty instance of str' - -# # should fail with invalid attribute key -# with pytest.raises(AttributeError) as e: -# Attributes(obj, '', {}, {}) -# assert str(e.value) == 'key_pattern must be a non-empty instance of str' -# -# # should fail with invalid attribute validators -# with pytest.raises(AttributeError) as e: -# Attributes(obj, 'valid-attributes-key', [], {}) -# assert str(e.value) == 'validators must be an instance of dict' -# -# # should fail with invalid attribute transcoders -# with pytest.raises(AttributeError) as e: -# Attributes(obj, 'valid-attributes-key', {}, []) -# assert str(e.value) == 'transcoders must be an instance of dict' -# -# # should work -# attrs = Attributes(obj, 'valid-attributes-key', {}, {}) -# assert attrs is not None - -#def testModel_attributes_gets_invalid_parameters(): -# # should work -# rootModel = _Database(InMemoryBackend()) -# validators = {'attr': lambda v: True} -# entity_attrs = Attributes(rootModel, 'valid-attributes-key', validators, {}) -# assert entity_attrs is not None -# -# with pytest.raises(AttributeError) as e: -# entity_attrs.update(update_attributes={'non-defined-attr': 'random-value'}) -# assert str(e.value) == "Unexpected update_attributes: {'non-defined-attr': 'random-value'}" -# -# with pytest.raises(AttributeError) as e: -# entity_attrs.update(remove_attributes=['non-defined-attr']) -# assert str(e.value) == "Unexpected remove_attributes: {'non-defined-attr'}" diff --git a/src/common/orm/tests/test_unitary_orm.py b/src/common/orm/tests/test_unitary_orm.py new file mode 100644 index 0000000000000000000000000000000000000000..7ae5a1a79363aab2728a790cc35e32cbbcf23355 --- /dev/null +++ b/src/common/orm/tests/test_unitary_orm.py @@ -0,0 +1,247 @@ +import logging, pytest +from common.orm._Database import _Database +from common.orm.backend._Backend import _Backend +from common.orm.backend.inmemory.InMemoryBackend import LOGGER, InMemoryBackend +from common.orm.fields.BooleanField import BooleanField +from common.orm.fields.FloatField import FloatField +from common.orm.fields.ForeignKeyField import ForeignKeyField +from common.orm.fields.IntegerField import IntegerField +from common.orm.fields.PrimaryKeyField import PrimaryKeyField +from common.orm.fields.StringField import StringField +from common.orm.model.Model import Model + +logging.basicConfig(level=logging.INFO) + +def test_database_instantiation(): + with pytest.raises(AttributeError) as e: + _Database(None) + str_class_path = '{}.{}'.format(_Backend.__module__, _Backend.__name__) + assert str(e.value) == 'backend must inherit from {}'.format(str_class_path) + + assert _Database(InMemoryBackend()) is not None + +def test_model_without_attributes(): + with pytest.raises(AttributeError) as e: + Model(None, 'valid-uuid') + str_class_path = '{}.{}'.format(Model.__module__, Model.__name__) + assert str(e.value) == 'parent must inherit from {}'.format(str_class_path) + + database = _Database(InMemoryBackend()) + + with pytest.raises(AttributeError) as e: + Model(database, '') + assert str(e.value) == 'Unable to set primary_key() since no PrimaryKeyField is defined in the model' + + with pytest.raises(AttributeError) as e: + Model(database, 'primary-key') + assert str(e.value) == 'Unable to set primary_key(primary-key) since no PrimaryKeyField is defined in the model' + +def test_model_with_primarykey(): + database = _Database(InMemoryBackend()) + + with pytest.raises(AttributeError) as e: + class WrongTestModel(Model): # pylint: disable=unused-variable + pk = PrimaryKeyField() + name = StringField(min_length=1) + age = IntegerField(min_value=0) + salary = FloatField(min_value=0.0) + active = BooleanField() + pk2 = PrimaryKeyField() + assert str(e.value) == 'PrimaryKey for Model(WrongTestModel) already set to attribute(pk)' + + class TestModel(Model): + pk = PrimaryKeyField() + name = StringField(min_length=1) + age = IntegerField(min_value=0) + salary = FloatField(min_value=0.0) + active = BooleanField() + + with pytest.raises(ValueError) as e: + TestModel(database) + assert str(e.value) == 'pk(None) is None.' + + with pytest.raises(ValueError) as e: + TestModel(database, '') + assert str(e.value) == 'pk() is out of range: allow_empty(False).' + + obj = TestModel(database, 'valid-pk') + assert obj is not None + + with pytest.raises(ValueError) as e: + obj.pk = 'another-valid-pk' + assert str(e.value) == 'PrimaryKeyField cannot be modified' + +def test_model_with_primarykey_and_attributes(): + database = _Database(InMemoryBackend()) + + class TestModel(Model): + pk = PrimaryKeyField() + name = StringField(min_length=5, max_length=10) + age = IntegerField(min_value=0) + salary = FloatField(min_value=0.0) + active = BooleanField() + + obj = TestModel(database, 'valid-pk') + assert obj is not None + + with pytest.raises(AttributeError) as e: + del obj.name + assert str(e.value) == 'Attribute "name" cannot be deleted' + + with pytest.raises(TypeError) as e: + obj.name = 55 + assert str(e.value) == "name(55) is of a wrong type(int). Accepted type_or_types(<class 'str'>)." + + with pytest.raises(TypeError) as e: + obj.name = 55.5 + assert str(e.value) == "name(55.5) is of a wrong type(float). Accepted type_or_types(<class 'str'>)." + + with pytest.raises(TypeError) as e: + obj.name = True + assert str(e.value) == "name(True) is of a wrong type(bool). Accepted type_or_types(<class 'str'>)." + + with pytest.raises(TypeError) as e: + obj.age = 'too old' + assert str(e.value) == "age(too old) is of a wrong type(str). Accepted type_or_types(<class 'int'>)." + + with pytest.raises(TypeError) as e: + obj.age = 37.5 + assert str(e.value) == "age(37.5) is of a wrong type(float). Accepted type_or_types(<class 'int'>)." + + with pytest.raises(TypeError) as e: + obj.salary = 'too high' + msg = "salary(too high) is of a wrong type(str). Accepted type_or_types((<class 'int'>, <class 'float'>))." + assert str(e.value) == msg + + with pytest.raises(TypeError) as e: + obj.active = 'active' + assert str(e.value) == "active(active) is of a wrong type(str). Accepted type_or_types(<class 'bool'>)." + + with pytest.raises(TypeError) as e: + obj.active = 27 + assert str(e.value) == "active(27) is of a wrong type(int). Accepted type_or_types(<class 'bool'>)." + + with pytest.raises(TypeError) as e: + obj.active = 92.5 + assert str(e.value) == "active(92.5) is of a wrong type(float). Accepted type_or_types(<class 'bool'>)." + + with pytest.raises(ValueError) as e: + obj.name = '' + assert str(e.value) == 'name() is out of range: allow_empty(False).' + + with pytest.raises(ValueError) as e: + obj.name = 'John' + assert str(e.value) == 'name(John) is out of range: min_length(5).' + + with pytest.raises(ValueError) as e: + obj.name = 'John Smith Willson' + assert str(e.value) == 'name(John Smith Willson) is out of range: max_value(10).' + + obj.name = 'John Smith' + obj.age = 37 + obj.salary = 5023.52 + obj.active = True + assert repr(obj) == "TestModel(pk='valid-pk'(PK), name='John Smith', age=37, salary=5023.52, active=True)" + +def test_model_database_operations(): + database = _Database(InMemoryBackend()) + + class TestModel(Model): + pk = PrimaryKeyField() + name = StringField(min_length=5, max_length=10) + age = IntegerField(min_value=0) + salary = FloatField(min_value=0.0) + active = BooleanField() + + obj = TestModel(database, 'valid-pk') + assert obj is not None + + obj.name = 'John Smith' + obj.age = 37 + obj.salary = 5023.52 + obj.active = True + assert repr(obj) == "TestModel(pk='valid-pk'(PK), name='John Smith', age=37, salary=5023.52, active=True)" + + obj.save() + + database_dump = database.dump() + assert len(database_dump) == 1 + db_entry_repr = '[dict] /TestModel[valid-pk]'\ + ' '\ + ':: {\'pk\': \'valid-pk\', \'name\': \'John Smith\', \'age\': \'37\', \'salary\': \'5023.52\', '\ + '\'active\': \'True\'}' + assert database_dump[0] == db_entry_repr + + obj2 = TestModel(database, 'valid-pk') + assert obj2 is not None + + obj2.load() + assert repr(obj2) == "TestModel(pk='valid-pk'(PK), name='John Smith', age=37, salary=5023.52, active=True)" + + obj2.delete() + assert len(database.dump()) == 0 + + obj2.save() + assert len(database.dump()) == 1 + + database.clear_all() + assert len(database.dump()) == 0 + +def test_model_with_foreignkeys(): + database = _Database(InMemoryBackend()) + + class Team(Model): + pk = PrimaryKeyField() + name = StringField(max_length=10) + + class Workplace(Model): + pk = PrimaryKeyField() + name = StringField(max_length=10) + + class Member(Model): + pk = PrimaryKeyField() + place = ForeignKeyField(Workplace) + name = StringField(max_length=10) + + team_dev_ops = Team(database, 'dev-ops') + team_dev_ops.name = 'Dev Ops' + assert team_dev_ops is not None + assert repr(team_dev_ops) == "Team(pk='dev-ops'(PK), name='Dev Ops')" + + workplace_bcn = Workplace(database, 'bcn') + workplace_bcn.name = 'Barcelona' + assert workplace_bcn is not None + assert repr(workplace_bcn) == "Workplace(pk='bcn'(PK), name='Barcelona')" + + member_john = Member(team_dev_ops, 'john') + member_john.name = 'John' + member_john.place = workplace_bcn + assert member_john is not None + assert repr(member_john) == "Member(pk='john'(PK), place='/Workplace[bcn]', name='John')" + + with pytest.raises(ValueError) as e: + member_john.save() + assert str(e.value) == 'Required Keys (/Team[dev-ops], /Workplace[bcn]) does not exist' + + workplace_bcn.save() + + with pytest.raises(ValueError) as e: + member_john.save() + assert str(e.value) == 'Required Keys (/Team[dev-ops]) does not exist' + + team_dev_ops.save() + member_john.save() + + database_dump = database.dump() + LOGGER.info('----- Database Dump: -----') + for database_entry in database_dump: + LOGGER.info(' ' + database_entry) + LOGGER.info('--------------------------') + #assert len(database_dump) == 1 + raise Exception() + + +# TODO: Test remove foreign key +# TODO: Test change foreign key +# TODO: Test remove entity with foreign keys pointing to another entity +# TODO: Test remove entity with primary key being pointed by another entity diff --git a/src/common/type_checkers/Checkers.py b/src/common/type_checkers/Checkers.py index 1876321be3e00c7e521446a3da525ebe725abe8a..b3693d87d29dc2a9c34343bfb8eb1cc69d1c2381 100644 --- a/src/common/type_checkers/Checkers.py +++ b/src/common/type_checkers/Checkers.py @@ -16,6 +16,11 @@ def chk_type(name : str, value : Any, type_or_types : Union[type, Set[type]] = s msg = '{}({}) is of a wrong type({}). Accepted type_or_types({}).' raise TypeError(msg.format(str(name), str(value), type(value).__name__, str(type_or_types))) +def chk_issubclass(name : str, value : type, class_or_classes : Union[type, Set[type]] = set()) -> Any: + if issubclass(value, class_or_classes): return value + msg = '{}({}) is of a wrong class({}). Accepted class_or_classes({}).' + raise TypeError(msg.format(str(name), str(value), type(value).__name__, str(class_or_classes))) + def chk_length( name : str, value : Sized, allow_empty : bool = False, min_length : Optional[int] = None, max_length : Optional[int] = None) -> Any: