class BaseTranslator(object): """ Generic class for translator. It contains the methods required to build a related QueryBuilder object """ # pylint: disable=too-many-instance-attributes,fixme # A label associated to the present class __label__ = None # The AiiDA class one-to-one associated to the present class _aiida_class = None # The string name of the AiiDA class _aiida_type = None # If True (False) the corresponding AiiDA class has (no) uuid property _has_uuid = None _result_type = __label__ _default = _default_projections = ['**'] _is_qb_initialized = False _is_id_query = None _total_count = None def __init__(self, **kwargs): """ Initialise the parameters. Create the basic query_help keyword Class (default None but becomes this class): is the class from which one takes the initial values of the attributes. By default is this class so that class atributes are translated into object attributes. In case of inheritance one cane use the same constructore but pass the inheriting class to pass its attributes. """ # Basic filter (dict) to set the identity of the uuid. None if # no specific node is requested self._id_filter = None # basic query_help object self._query_help = { 'path': [{ 'cls': self._aiida_class, 'tag': self.__label__ }], 'filters': {}, 'project': {}, 'order_by': {} } # query_builder object (No initialization) self.qbobj = QueryBuilder() self.limit_default = kwargs['LIMIT_DEFAULT'] self.schema = None def __repr__(self): """ This function is required for the caching system to be able to compare two NodeTranslator objects. Comparison is done on the value returned by __repr__ :return: representation of NodeTranslator objects. Returns nothing because the inputs of self.get_nodes are sufficient to determine the identity of two queries. """ return '' @staticmethod def get_projectable_properties(): """ This method is extended in specific translators classes. It returns a dict as follows: dict(fields=projectable_properties, ordering=ordering) where projectable_properties is a dict and ordering is a list """ return {} def init_qb(self): """ Initialize query builder object by means of _query_help """ self.qbobj.__init__(**self._query_help) self._is_qb_initialized = True def count(self): """ Count the number of rows returned by the query and set total_count """ if self._is_qb_initialized: self._total_count = self.qbobj.count() else: raise InvalidOperation( 'query builder object has not been initialized.') # def caching_method(self): # """ # class method for caching. It is a wrapper of the # flask_cache memoize # method. To be used as a decorator # :return: the flask_cache memoize method with the timeout kwarg # corrispondent to the class # """ # return cache.memoize() # # @cache.memoize(timeout=CACHING_TIMEOUTS[self.__label__]) def get_total_count(self): """ Returns the number of rows of the query. :return: total_count """ ## Count the results if needed if not self._total_count: self.count() return self._total_count def set_filters(self, filters=None): """ Add filters in query_help. :param filters: it is a dictionary where keys are the tag names given in the path in query_help and their values are the dictionary of filters want to add for that tag name. Format for the Filters dictionary:: filters = { "tag1" : {k1:v1, k2:v2}, "tag2" : {k1:v1, k2:v2}, } :return: query_help dict including filters if any. """ if filters is None: filters = {} if isinstance(filters, dict): # pylint: disable=too-many-nested-blocks if filters: for tag, tag_filters in filters.items(): if tag_filters and isinstance(tag_filters, dict): self._query_help['filters'][tag] = {} for filter_key, filter_value in tag_filters.items(): if filter_key == 'pk': filter_key = PK_DBSYNONYM self._query_help['filters'][tag][filter_key] \ = filter_value else: raise InputValidationError( 'Pass data in dictionary format where ' 'keys are the tag names given in the ' 'path in query_help and and their values' ' are the dictionary of filters want ' 'to add for that tag name.') def get_default_projections(self): """ method to get default projections of the node :return: self._default_projections """ return self._default_projections def set_default_projections(self): """ It calls the set_projections() methods internally to add the default projections in query_help :return: None """ self.set_projections({self.__label__: self._default_projections}) def set_projections(self, projections): """ add the projections in query_help :param projections: it is a dictionary where keys are the tag names given in the path in query_help and values are the list of the names you want to project in the final output :return: updated query_help with projections """ if isinstance(projections, dict): if projections: for project_key, project_list in projections.items(): self._query_help['project'][project_key] = project_list else: raise InputValidationError('Pass data in dictionary format where ' 'keys are the tag names given in the ' 'path in query_help and values are the ' 'list of the names you want to project ' 'in the final output') def set_order(self, orders): """ Add order_by clause in query_help :param orders: dictionary of orders you want to apply on final results :return: None or exception if any. """ ## Validate input if not isinstance(orders, dict): raise InputValidationError('orders has to be a dictionary' "compatible with the 'order_by' section" 'of the query_help') ## Auxiliary_function to get the ordering cryterion def def_order(columns): """ Takes a list of signed column names ex. ['id', '-ctime', '+mtime'] and transforms it in a order_by compatible dictionary :param columns: (list of strings) :return: a dictionary """ from collections import OrderedDict order_dict = OrderedDict() for column in columns: if column[0] == '-': order_dict[column[1:]] = 'desc' elif column[0] == '+': order_dict[column[1:]] = 'asc' else: order_dict[column] = 'asc' if 'pk' in order_dict: order_dict[PK_DBSYNONYM] = order_dict.pop('pk') return order_dict ## Assign orderby field query_help if 'id' not in orders[self._result_type] and '-id' not in orders[ self._result_type]: orders[self._result_type].append('id') for tag, columns in orders.items(): self._query_help['order_by'][tag] = def_order(columns) def set_query(self, filters=None, orders=None, projections=None, query_type=None, node_id=None, attributes=None, attributes_filter=None, extras=None, extras_filter=None): # pylint: disable=too-many-arguments,unused-argument,too-many-locals,too-many-branches """ Adds filters, default projections, order specs to the query_help, and initializes the qb object :param filters: dictionary with the filters :param orders: dictionary with the order for each tag :param projections: dictionary with the projection. It is discarded if query_type=='attributes'/'extras' :param query_type: (string) specify the result or the content ("attr") :param id: (integer) id of a specific node :param filename: name of the file to return its content :param attributes: flag to show attributes in nodes endpoint :param attributes_filter: list of node attributes to query :param extras: flag to show attributes in nodes endpoint :param extras_filter: list of node extras to query """ tagged_filters = {} ## Check if filters are well defined and construct an ad-hoc filter # for id_query if node_id is not None: self._is_id_query = True if self._result_type == self.__label__ and filters: raise RestInputValidationError( 'selecting a specific id does not allow to specify filters' ) try: self._check_id_validity(node_id) except RestValidationError as exc: raise RestValidationError(str(exc)) else: tagged_filters[self.__label__] = self._id_filter if self._result_type is not self.__label__: tagged_filters[self._result_type] = filters else: tagged_filters[self.__label__] = filters ## Add filters self.set_filters(tagged_filters) ## Add projections if projections is None: if attributes is None and extras is None: self.set_default_projections() else: default_projections = self.get_default_projections() if attributes is True: if attributes_filter is None: default_projections.append('attributes') else: ## Check if attributes_filter is not a list if not isinstance(attributes_filter, list): attributes_filter = [attributes_filter] for attr in attributes_filter: default_projections.append('attributes.' + str(attr)) elif attributes is not None and attributes is not False: raise RestValidationError( 'The attributes filter is false by default and can only be set to true.' ) if extras is True: if extras_filter is None: default_projections.append('extras') else: ## Check if extras_filter is not a list if not isinstance(extras_filter, list): extras_filter = [extras_filter] for extra in extras_filter: default_projections.append('extras.' + str(extra)) elif extras is not None and extras is not False: raise RestValidationError( 'The extras filter is false by default and can only be set to true.' ) self.set_projections({self.__label__: default_projections}) else: tagged_projections = {self._result_type: projections} self.set_projections(tagged_projections) ##Add order_by if orders is not None: tagged_orders = {self._result_type: orders} self.set_order(tagged_orders) ## Initialize the query_object self.init_qb() def get_query_help(self): """ :return: return QB json dictionary """ return self._query_help def set_limit_offset(self, limit=None, offset=None): """ sets limits and offset directly to the query_builder object :param limit: :param offset: :return: """ ## mandatory params # none ## non-mandatory params if limit is not None: try: limit = int(limit) except ValueError: raise InputValidationError('Limit value must be an integer') if limit > self.limit_default: raise RestValidationError( 'Limit and perpage cannot be bigger than {}'.format( self.limit_default)) else: limit = self.limit_default if offset is not None: try: offset = int(offset) except ValueError: raise InputValidationError('Offset value must be an integer') if self._is_qb_initialized: if limit is not None: self.qbobj.limit(limit) else: pass if offset is not None: self.qbobj.offset(offset) else: pass else: raise InvalidOperation( 'query builder object has not been initialized.') def get_formatted_result(self, label): """ Runs the query and retrieves results tagged as "label". :param label: the tag of the results to be extracted out of the query rows. :type label: str :return: a list of the query results """ if not self._is_qb_initialized: raise InvalidOperation( 'query builder object has not been initialized.') results = [] if self._total_count > 0: for res in self.qbobj.dict(): tmp = res[label] # Note: In code cleanup and design change, remove this node dependant part # from base class and move it to node translator. if self._result_type in ['with_outgoing', 'with_incoming']: tmp['link_type'] = res[self.__label__ + '--' + label]['type'] tmp['link_label'] = res[self.__label__ + '--' + label]['label'] results.append(tmp) # TODO think how to make it less hardcoded if self._result_type == 'with_outgoing': result = {'incoming': results} elif self._result_type == 'with_incoming': result = {'outgoing': results} else: result = {self.__label__: results} return result def get_results(self): """ Returns either list of nodes or details of single node from database. :return: either list of nodes or details of single node from database """ ## Check whether the querybuilder object has been initialized if not self._is_qb_initialized: raise InvalidOperation( 'query builder object has not been initialized.') ## Count the total number of rows returned by the query (if not # already done) if self._total_count is None: self.count() ## Retrieve data data = self.get_formatted_result(self._result_type) return data def _check_id_validity(self, node_id): """ Checks whether id corresponds to an object of the expected type, whenever type is a valid column of the database (ex. for nodes, but not for users) :param node_id: id (or id starting pattern) :return: True if node_id valid, False if invalid. If True, sets the id filter attribute correctly :raise RestValidationError: if no node is found or id pattern does not identify a unique node """ from aiida.common.exceptions import MultipleObjectsError, NotExistent from aiida.orm.utils.loaders import IdentifierType, get_loader loader = get_loader(self._aiida_class) if self._has_uuid: # For consistency check that id is a string if not isinstance(node_id, six.string_types): raise RestValidationError('parameter id has to be a string') identifier_type = IdentifierType.UUID qbobj, _ = loader.get_query_builder( node_id, identifier_type, sub_classes=(self._aiida_class, )) else: # Similarly, check that id is an integer if not isinstance(node_id, int): raise RestValidationError('parameter id has to be an integer') identifier_type = IdentifierType.ID qbobj, _ = loader.get_query_builder( node_id, identifier_type, sub_classes=(self._aiida_class, )) # For efficiency I don't go further than two results qbobj.limit(2) try: pk = qbobj.one()[0].pk except MultipleObjectsError: raise RestInputValidationError( 'More than one node found. Provide longer starting pattern for id.' ) except NotExistent: raise RestInputValidationError( "either no object's id starts" " with '{}' or the corresponding object" ' is not of type aiida.orm.{}'.format(node_id, self._aiida_type)) else: # create a permanent filter self._id_filter = {'id': {'==': pk}} return True
class BaseTranslator(object): """ Generic class for translator. It contains the methods required to build a related QueryBuilder object """ # A label associated to the present class __label__ = None # The AiiDA class one-to-one associated to the present class _aiida_class = None # The string name of the AiiDA class _aiida_type = None # The string associated to the AiiDA class in the query builder lexicon _qb_type = None # If True (False) the corresponding AiiDA class has (no) uuid property _has_uuid = None _result_type = __label__ _default = _default_projections = ["**"] _schema_projections = { "column_order": [], "additional_info": {} } _is_qb_initialized = False _is_id_query = None _total_count = None def __init__(self, Class=None, **kwargs): """ Initialise the parameters. Create the basic query_help keyword Class (default None but becomes this class): is the class from which one takes the initial values of the attributes. By default is this class so that class atributes are translated into object attributes. In case of inheritance one cane use the same constructore but pass the inheriting class to pass its attributes. """ # Assume default class is this class (cannot be done in the # definition as it requires self) if Class is None: Class = self.__class__ # Assign class parameters to the object self.__label__ = Class.__label__ self._aiida_class = Class._aiida_class self._aiida_type = Class._aiida_type self._qb_type = Class._qb_type self._result_type = Class.__label__ self._default = Class._default self._default_projections = Class._default_projections self._schema_projections = Class._schema_projections self._is_qb_initialized = Class._is_qb_initialized self._is_id_query = Class._is_id_query self._total_count = Class._total_count # Basic filter (dict) to set the identity of the uuid. None if # no specific node is requested self._id_filter = None # basic query_help object self._query_help = { "path": [{ "type": self._qb_type, "label": self.__label__ }], "filters": {}, "project": {}, "order_by": {} } # query_builder object (No initialization) self.qb = QueryBuilder() self.LIMIT_DEFAULT = kwargs['LIMIT_DEFAULT'] self.schema = None def __repr__(self): """ This function is required for the caching system to be able to compare two NodeTranslator objects. Comparison is done on the value returned by __repr__ :return: representation of NodeTranslator objects. Returns nothing because the inputs of self.get_nodes are sufficient to determine the identity of two queries. """ return "" def get_schema(self): # Construct the full class string class_string = 'aiida.orm.' + self._aiida_type # Load correspondent orm class orm_class = get_object_from_string(class_string) # Construct the json object to be returned basic_schema = orm_class.get_schema() schema = {} ordering = [] # get addional info and column order from translator class # and combine it with basic schema if len(self._schema_projections["column_order"]) > 0: for field in self._schema_projections["column_order"]: # basic schema if field in basic_schema.keys(): schema[field] = basic_schema[field] else: ## Note: if column name starts with user_* get the schema information from # user class. It is added mainly to handle user_email case. # TODO need to improve field_parts = field.split("_") if field_parts[0] == "user" and field != "user_id" and len(field_parts) > 1: from aiida.orm.user import User user_schema = User.get_schema() if field_parts[1] in user_schema.keys(): schema[field] = user_schema[field_parts[1]] else: raise KeyError("{} is not present in user schema".format(field)) else: raise KeyError("{} is not present in ORM basic schema".format(field)) # additional info defined in translator class if field in self._schema_projections["additional_info"]: schema[field].update(self._schema_projections["additional_info"][field]) else: raise KeyError("{} is not present in default projection additional info".format(field)) # order ordering = self._schema_projections["column_order"] else: raise ConfigurationError("Define column order to get schema for {}".format(self._aiida_type)) return dict(fields=schema, ordering=ordering) def init_qb(self): """ Initialize query builder object by means of _query_help """ self.qb.__init__(**self._query_help) self._is_qb_initialized = True def count(self): """ Count the number of rows returned by the query and set total_count """ if self._is_qb_initialized: self._total_count = self.qb.count() else: raise InvalidOperation("query builder object has not been " "initialized.") # def caching_method(self): # """ # class method for caching. It is a wrapper of the # flask_cache memoize # method. To be used as a decorator # :return: the flask_cache memoize method with the timeout kwarg # corrispondent to the class # """ # return cache.memoize() # # @cache.memoize(timeout=CACHING_TIMEOUTS[self.__label__]) def get_total_count(self): """ Returns the number of rows of the query. :return: total_count """ ## Count the results if needed if not self._total_count: self.count() return self._total_count def set_filters(self, filters={}): """ Add filters in query_help. :param filters: it is a dictionary where keys are the tag names given in the path in query_help and their values are the dictionary of filters want to add for that tag name. Format for the Filters dictionary:: filters = { "tag1" : {k1:v1, k2:v2}, "tag2" : {k1:v1, k2:v2}, } :return: query_help dict including filters if any. """ if isinstance(filters, dict): if len(filters) > 0: for tag, tag_filters in filters.iteritems(): if len(tag_filters) > 0 and isinstance(tag_filters, dict): self._query_help["filters"][tag] = {} for filter_key, filter_value in tag_filters.iteritems(): if filter_key == "pk": filter_key = pk_dbsynonym self._query_help["filters"][tag][filter_key] \ = filter_value else: raise InputValidationError("Pass data in dictionary format where " "keys are the tag names given in the " "path in query_help and and their values" " are the dictionary of filters want " "to add for that tag name.") def get_default_projections(self): """ method to get default projections of the node :return: self._default_projections """ return self._default_projections def set_default_projections(self): """ It calls the set_projections() methods internally to add the default projections in query_help :return: None """ self.set_projections({self.__label__: self._default_projections}) def set_projections(self, projections): """ add the projections in query_help :param projections: it is a dictionary where keys are the tag names given in the path in query_help and values are the list of the names you want to project in the final output :return: updated query_help with projections """ if isinstance(projections, dict): if len(projections) > 0: for project_key, project_list in projections.iteritems(): self._query_help["project"][project_key] = project_list else: raise InputValidationError("Pass data in dictionary format where " "keys are the tag names given in the " "path in query_help and values are the " "list of the names you want to project " "in the final output") def set_order(self, orders): """ Add order_by clause in query_help :param orders: dictionary of orders you want to apply on final results :return: None or exception if any. """ ## Validate input if type(orders) is not dict: raise InputValidationError("orders has to be a dictionary" "compatible with the 'order_by' section" "of the query_help") ## Auxiliary_function to get the ordering cryterion def def_order(columns): """ Takes a list of signed column names ex. ['id', '-ctime', '+mtime'] and transforms it in a order_by compatible dictionary :param columns: (list of strings) :return: a dictionary """ order_dict = {} for column in columns: if column[0] == '-': order_dict[column[1:]] = 'desc' elif column[0] == '+': order_dict[column[1:]] = 'asc' else: order_dict[column] = 'asc' if order_dict.has_key('pk'): order_dict[pk_dbsynonym] = order_dict.pop('pk') return order_dict ## Assign orderby field query_help for tag, columns in orders.iteritems(): self._query_help['order_by'][tag] = def_order(columns) def set_query(self, filters=None, orders=None, projections=None, id=None): """ Adds filters, default projections, order specs to the query_help, and initializes the qb object :param filters: dictionary with the filters :param orders: dictionary with the order for each tag :param orders: dictionary with the projections :param id: id of a specific node :type id: int """ tagged_filters = {} ## Check if filters are well defined and construct an ad-hoc filter # for id_query if id is not None: self._is_id_query = True if self._result_type == self.__label__ and len(filters) > 0: raise RestInputValidationError("selecting a specific id does " "not " "allow to specify filters") try: self._check_id_validity(id) except RestValidationError as e: raise RestValidationError(e.message) else: tagged_filters[self.__label__] = self._id_filter if self._result_type is not self.__label__: tagged_filters[self._result_type] = filters else: tagged_filters[self.__label__] = filters ## Add filters self.set_filters(tagged_filters) ## Add projections if projections is None: self.set_default_projections() else: tagged_projections = {self._result_type: projections} self.set_projections(tagged_projections) ##Add order_by if orders is not None: tagged_orders = {self._result_type: orders} self.set_order(tagged_orders) ## Initialize the query_object self.init_qb() def get_query_help(self): """ :return: return QB json dictionary """ return self._query_help def set_limit_offset(self, limit=None, offset=None): """ sets limits and offset directly to the query_builder object :param limit: :param offset: :return: """ ## mandatory params # none ## non-mandatory params if limit is not None: try: limit = int(limit) except ValueError: raise InputValidationError("Limit value must be an integer") if limit > self.LIMIT_DEFAULT: raise RestValidationError("Limit and perpage cannot be bigger " "than {}".format(self.LIMIT_DEFAULT)) else: limit = self.LIMIT_DEFAULT if offset is not None: try: offset = int(offset) except ValueError: raise InputValidationError("Offset value must be an " "integer") if self._is_qb_initialized: if limit is not None: self.qb.limit(limit) else: pass if offset is not None: self.qb.offset(offset) else: pass else: raise InvalidOperation("query builder object has not been " "initialized.") def get_formatted_result(self, label): """ Runs the query and retrieves results tagged as "label". :param label: the tag of the results to be extracted out of the query rows. :type label: str :return: a list of the query results """ if not self._is_qb_initialized: raise InvalidOperation("query builder object has not been " "initialized.") results = [] if self._total_count > 0: results = [res[label] for res in self.qb.dict()] # TODO think how to make it less hardcoded if self._result_type == 'input_of': return {'inputs': results} elif self._result_type == 'output_of': return {'outputs': results} else: return {self.__label__: results} def get_results(self): """ Returns either list of nodes or details of single node from database. :return: either list of nodes or details of single node from database """ ## Check whether the querybuilder object has been initialized if not self._is_qb_initialized: raise InvalidOperation("query builder object has not been " "initialized.") ## Count the total number of rows returned by the query (if not # already done) if self._total_count is None: self.count() ## Retrieve data data = self.get_formatted_result(self._result_type) return data def _check_id_validity(self, id): """ Checks whether id corresponds to an object of the expected type, whenever type is a valid column of the database (ex. for nodes, but not for users) :param id: id (or id starting pattern) :return: True if id valid, False if invalid. If True, sets the id filter attribute correctly :raise RestValidationError: if no node is found or id pattern does not identify a unique node """ from aiida.common.exceptions import MultipleObjectsError, NotExistent from aiida.orm.utils.loaders import IdentifierType, get_loader loader = get_loader(self._aiida_class) if self._has_uuid: # For consistency check that tid is a string if not isinstance(id, (str, unicode)): raise RestValidationError('parameter id has to be an string') identifier_type = IdentifierType.UUID qb, _ = loader.get_query_builder(id, identifier_type, sub_classes=(self._aiida_class,)) else: # Similarly, check that id is an integer if not isinstance(id, int): raise RestValidationError('parameter id has to be an integer') identifier_type = IdentifierType.ID qb, _ = loader.get_query_builder(id, identifier_type, sub_classes=(self._aiida_class,)) # For efficiency I don't go further than two results qb.limit(2) try: pk = qb.one()[0].pk except MultipleObjectsError: raise RestValidationError("More than one node found." " Provide longer starting pattern" " for id.") except NotExistent: raise RestValidationError("either no object's id starts" " with '{}' or the corresponding object" " is not of type aiida.orm.{}" .format(id, self._aiida_type)) else: # create a permanent filter self._id_filter = {'id': {'==': pk}} return True
class BaseTranslator(object): """ Generic class for translator. It contains the methods required to build a related QueryBuilder object """ # A label associated to the present class __label__ = None # The AiiDA class one-to-one associated to the present class _aiida_class = None # The string name of the AiiDA class _aiida_type = None # The string associated to the AiiDA class in the query builder lexicon _qb_type = None # If True (False) the corresponding AiiDA class has (no) uuid property _has_uuid = None _result_type = __label__ _default = _default_projections = [] _is_qb_initialized = False _is_id_query = None _total_count = None def __init__(self, Class=None, **kwargs): """ Initialise the parameters. Create the basic query_help keyword Class (default None but becomes this class): is the class from which one takes the initial values of the attributes. By default is this class so that class atributes are translated into object attributes. In case of inheritance one cane use the same constructore but pass the inheriting class to pass its attributes. """ # Assume default class is this class (cannot be done in the # definition as it requires self) if Class is None: Class = self.__class__ # Assign class parameters to the object self.__label__ = Class.__label__ self._aiida_class = Class._aiida_class self._aiida_type = Class._aiida_type self._qb_type = Class._qb_type self._result_type = Class.__label__ self._default = Class._default self._default_projections = Class._default_projections self._is_qb_initialized = Class._is_qb_initialized self._is_id_query = Class._is_id_query self._total_count = Class._total_count # Basic filter (dict) to set the identity of the uuid. None if # no specific node is requested self._id_filter = None # basic query_help object self._query_help = { "path": [{ "type": self._qb_type, "label": self.__label__ }], "filters": {}, "project": {}, "order_by": {} } # query_builder object (No initialization) self.qb = QueryBuilder() self.LIMIT_DEFAULT = kwargs['LIMIT_DEFAULT'] if 'custom_schema' in kwargs: self.custom_schema = kwargs['custom_schema'] else: self.custom_schema = None def __repr__(self): """ This function is required for the caching system to be able to compare two NodeTranslator objects. Comparison is done on the value returned by __repr__ :return: representation of NodeTranslator objects. Returns nothing because the inputs of self.get_nodes are sufficient to determine the identity of two queries. """ return "" def get_schema(self): # Construct the full class string class_string = 'aiida.orm.' + self._aiida_type # Load correspondent orm class orm_class = get_object_from_string(class_string) # Construct the json object to be returned basic_schema = orm_class.get_db_columns() """ Determine the API schema (spartially overlapping with the ORM/database one). When the ORM is based on django, however, attributes and extras are not colums of the database but are nevertheless valid projections. We add them by hand into the API schema. """ # TODO change the get_db_columns method to include also relationships such as attributes, extras, input, # and outputs in order to have a more complete definition of the schema. if self._default_projections == ['**']: schema = basic_schema # No custom schema, take the basic one else: # Non-schema possible projections (only for nodes when django is backend) non_schema_projs = ('attributes', 'extras') # Sub-projections of JSON fields (applies to both SQLA and Django) non_schema_proj_prefix = ('attributes.', 'extras.') schema_key = [] schema_values = [] for k in self._default_projections: if k in basic_schema.keys(): schema_key.append(k) schema_values.append(basic_schema[k]) elif k in non_schema_projs: # Catches 'attributes' and 'extras' schema_key.append(k) value = dict(type=dict, is_foreign_key=False) schema_values.append(value) elif k.startswith(non_schema_proj_prefix): # Catches 'attributes.<key>' and 'extras.<key>' schema_key.append(k) value = dict(type=None, is_foreign_key=False) schema_values.append(value) schema = dict(zip(schema_key, schema_values)) def table2resource(table_name): """ Convert the related_tablevalues to the RESTAPI resources (orm class/db table ==> RESTapi resource) :param table_name (str): name of the table (in SQLA is __tablename__) :return: resource_name (str): name of the API resource """ # TODO Consider ways to make this function backend independent (one # idea would be to go from table name to aiida class name which is # unique) if BACKEND == BACKEND_DJANGO: (spam, resource_name) = issingular(table_name[2:].lower()) elif BACKEND == BACKEND_SQLA: (spam, resource_name) = issingular(table_name[5:]) elif BACKEND is None: raise ConfigurationError("settings.BACKEND has not been set.\n" "Hint: Have you called " "aiida.load_dbenv?") else: raise ConfigurationError( "Unknown settings.BACKEND: {}".format(BACKEND)) return resource_name for k, v in schema.iteritems(): # Add custom fields to the column dictionaries if 'fields' in self.custom_schema: if k in self.custom_schema['fields'].keys(): schema[k].update(self.custom_schema['fields'][k]) # Convert python types values into strings schema[k]['type'] = str(schema[k]['type'])[7:-2] # Construct the 'related resource' field from the 'related_table' # field if v['is_foreign_key'] == True: schema[k]['related_resource'] = table2resource( schema[k].pop('related_table')) # TODO Construct the ordering (all these things have to be moved in matcloud_backend) if self._default_projections != ['**']: ordering = self._default_projections else: # random ordering if not set explicitely in ordering = schema.keys() return dict(fields=schema, ordering=ordering) def init_qb(self): """ Initialize query builder object by means of _query_help """ self.qb.__init__(**self._query_help) self._is_qb_initialized = True def count(self): """ Count the number of rows returned by the query and set total_count """ if self._is_qb_initialized: self._total_count = self.qb.count() else: raise InvalidOperation("query builder object has not been " "initialized.") # def caching_method(self): # """ # class method for caching. It is a wrapper of the # flask_cache memoize # method. To be used as a decorator # :return: the flask_cache memoize method with the timeout kwarg # corrispondent to the class # """ # return cache.memoize() # # @cache.memoize(timeout=CACHING_TIMEOUTS[self.__label__]) def get_total_count(self): """ Returns the number of rows of the query :return: total_count """ ## Count the results if needed if not self._total_count: self.count() return self._total_count def set_filters(self, filters={}): """ Add filters in query_help. :param filters: it is a dictionary where keys are the tag names given in the path in query_help and their values are the dictionary of filters want to add for that tag name. Format for the Filters dictionary: filters = { "tag1" : {k1:v1, k2:v2}, "tag2" : {k1:v1, k2:v2}, } :return: query_help dict including filters if any. """ if isinstance(filters, dict): if len(filters) > 0: for tag, tag_filters in filters.iteritems(): if len(tag_filters) > 0 and isinstance(tag_filters, dict): self._query_help["filters"][tag] = {} for filter_key, filter_value in tag_filters.iteritems( ): if filter_key == "pk": filter_key = pk_dbsynonym self._query_help["filters"][tag][filter_key] \ = filter_value else: raise InputValidationError( "Pass data in dictionary format where " "keys are the tag names given in the " "path in query_help and and their values" " are the dictionary of filters want " "to add for that tag name.") def get_default_projections(self): """ method to get default projections of the node :return: self._default_projections """ return self._default_projections def set_default_projections(self): """ It calls the set_projections() methods internally to add the default projections in query_help :return: None """ self.set_projections({self.__label__: self._default_projections}) def set_projections(self, projections): """ add the projections in query_help :param projections: it is a dictionary where keys are the tag names given in the path in query_help and values are the list of the names you want to project in the final output :return: updated query_help with projections """ if isinstance(projections, dict): if len(projections) > 0: for project_key, project_list in projections.iteritems(): self._query_help["project"][project_key] = project_list else: raise InputValidationError("Pass data in dictionary format where " "keys are the tag names given in the " "path in query_help and values are the " "list of the names you want to project " "in the final output") def set_order(self, orders): """ Add order_by clause in query_help :param orders: dictionary of orders you want to apply on final results :return: None or exception if any. """ ## Validate input if type(orders) is not dict: raise InputValidationError("orders has to be a dictionary" "compatible with the 'order_by' section" "of the query_help") ## Auxiliary_function to get the ordering cryterion def def_order(columns): """ Takes a list of signed column names ex. ['id', '-ctime', '+mtime'] and transforms it in a order_by compatible dictionary :param columns: (list of strings) :return: a dictionary """ order_dict = {} for column in columns: if column[0] == '-': order_dict[column[1:]] = 'desc' elif column[0] == '+': order_dict[column[1:]] = 'asc' else: order_dict[column] = 'asc' if order_dict.has_key('pk'): order_dict[pk_dbsynonym] = order_dict.pop('pk') return order_dict ## Assign orderby field query_help for tag, columns in orders.iteritems(): self._query_help['order_by'][tag] = def_order(columns) def set_query(self, filters=None, orders=None, projections=None, id=None): """ Adds filters, default projections, order specs to the query_help, and initializes the qb object :param filters: dictionary with the filters :param orders: dictionary with the order for each tag :param orders: dictionary with the projections :param id (integer): id of a specific node """ tagged_filters = {} ## Check if filters are well defined and construct an ad-hoc filter # for id_query if id is not None: self._is_id_query = True if self._result_type == self.__label__ and len(filters) > 0: raise RestInputValidationError("selecting a specific id does " "not " "allow to specify filters") try: self._check_id_validity(id) except RestValidationError as e: raise RestValidationError(e.message) else: tagged_filters[self.__label__] = self._id_filter if self._result_type is not self.__label__: tagged_filters[self._result_type] = filters else: tagged_filters[self.__label__] = filters ## Add filters self.set_filters(tagged_filters) ## Add projections if projections is None: self.set_default_projections() else: tagged_projections = {self._result_type: projections} self.set_projections(tagged_projections) ##Add order_by if orders is not None: tagged_orders = {self._result_type: orders} self.set_order(tagged_orders) ## Initialize the query_object self.init_qb() def get_query_help(self): """ :return: return QB json dictionary """ return self._query_help def set_limit_offset(self, limit=None, offset=None): """ sets limits and offset directly to the query_builder object :param limit: :param offset: :return: """ ## mandatory params # none ## non-mandatory params if limit is not None: try: limit = int(limit) except ValueError: raise InputValidationError("Limit value must be an integer") if limit > self.LIMIT_DEFAULT: raise RestValidationError("Limit and perpage cannot be bigger " "than {}".format(self.LIMIT_DEFAULT)) else: limit = self.LIMIT_DEFAULT if offset is not None: try: offset = int(offset) except ValueError: raise InputValidationError("Offset value must be an " "integer") if self._is_qb_initialized: if limit is not None: self.qb.limit(limit) else: pass if offset is not None: self.qb.offset(offset) else: pass else: raise InvalidOperation("query builder object has not been " "initialized.") def get_formatted_result(self, label): """ Runs the query and retrieves results tagged as "label" :param label (string): the tag of the results to be extracted out of the query rows. :return: a list of the query results """ if not self._is_qb_initialized: raise InvalidOperation("query builder object has not been " "initialized.") results = [] if self._total_count > 0: results = [res[label] for res in self.qb.dict()] # TODO think how to make it less hardcoded if self._result_type == 'input_of': return {'inputs': results} elif self._result_type == 'output_of': return {'outputs': results} else: return {self.__label__: results} def get_results(self): """ Returns either list of nodes or details of single node from database :return: either list of nodes or details of single node from database """ ## Check whether the querybuilder object has been initialized if not self._is_qb_initialized: raise InvalidOperation("query builder object has not been " "initialized.") ## Count the total number of rows returned by the query (if not # already done) if self._total_count is None: self.count() ## Retrieve data data = self.get_formatted_result(self._result_type) return data def _check_id_validity(self, id): """ Checks whether a id full id or id starting pattern) corresponds to an object of the expected type, whenever type is a valid column of the database (ex. for nodes, but not for users) :param id: id, or id starting pattern :return: True if id valid (invalid). If True, sets the id filter attribute correctly :raise: RestValidationError if No node is found or id pattern does not identify a unique node """ from aiida.common.exceptions import MultipleObjectsError, NotExistent from aiida.orm.utils import create_node_id_qb if self._has_uuid: # For consistency check that tid is a string if not isinstance(id, (str, unicode)): raise RestValidationError('parameter id has to be an string') qb = create_node_id_qb(uuid=id, parent_class=self._aiida_class) else: # Similarly, check that id is an integer if not isinstance(id, int): raise RestValidationError('parameter id has to be an integer') qb = create_node_id_qb(pk=id, parent_class=self._aiida_class) # project only the pk qb.add_projection('node', ['id']) # for efficiency i don;t go further than two results qb.limit(2) try: pk = qb.one()[0] except MultipleObjectsError: raise RestValidationError("More than one node found." " Provide longer starting pattern" " for id.") except NotExistent: raise RestValidationError("either no object's id starts" " with '{}' or the corresponding object" " is not of type aiida.orm.{}".format( id, self._aiida_type)) else: # create a permanent filter self._id_filter = {'id': {'==': pk}} return True
class BaseTranslator(object): """ Generic class for translator. It also contains all methods required to build QueryBuilder object """ # A label associated to the present class __label__ = None # The string name of the AiiDA class one-to-one associated to the present # class _aiida_type = None # The string associated to the AiiDA class in the query builder lexicon _qb_type = None _result_type = __label__ _default = _default_projections = [] _is_qb_initialized = False _is_pk_query = None _total_count = None def __init__(self, Class=None, **kwargs): """ Initialise the parameters. Create the basic query_help keyword Class (default None but becomes this class): is the class from which one takes the initial values of the attributes. By default is this class so that class atributes are translated into object attributes. In case of inheritance one cane use the same constructore but pass the inheriting class to pass its attributes. """ # Assume default class is this class (cannot be done in the # definition as it requires self) if Class is None: Class = self.__class__ # Assign class parameters to the object self.__label__ = Class.__label__ self._aiida_type = Class._aiida_type self._qb_type = Class._qb_type self._result_type = Class.__label__ self._default = Class._default self._default_projections = Class._default_projections self._is_qb_initialized = Class._is_qb_initialized self._is_pk_query = Class._is_pk_query self._total_count = Class._total_count # basic query_help object self._query_help = { "path": [{ "type": self._qb_type, "label": self.__label__ }], "filters": {}, "project": {}, "order_by": {} } # query_builder object (No initialization) self.qb = QueryBuilder() self.LIMIT_DEFAULT = kwargs['LIMIT_DEFAULT'] if 'custom_schema' in kwargs: self.custom_schema = kwargs['custom_schema'] else: self.custom_schema = None def __repr__(self): """ This function is required for the caching system to be able to compare two NodeTranslator objects. Comparison is done on the value returned by __repr__ :return: representation of NodeTranslator objects. Returns nothing because the inputs of self.get_nodes are sufficient to determine the identity of two queries. """ return "" def get_schema(self): # Construct the full class string class_string = 'aiida.orm.' + self._aiida_type # Load correspondent orm class orm_class = get_object_from_string(class_string) # Construct the json object to be returned basic_schema = orm_class.get_db_columns() if self._default_projections == ['**']: schema = basic_schema # No custom schema, take the basic one else: schema = dict([(k, basic_schema[k]) for k in self._default_projections if k in basic_schema.keys()]) # Convert the related_tablevalues to the RESTAPI resources # (orm class/db table ==> RESTapi resource) def table2resource(table_name): # TODO Consider ways to make this function backend independent (one # idea would be to go from table name to aiida class name which is # univoque) if BACKEND == BACKEND_DJANGO: (spam, resource_name) = issingular(table_name[2:].lower()) elif BACKEND == BACKEND_SQLA: (spam, resource_name) = issingular(table_name[5:]) elif BACKEND is None: raise ConfigurationError("settings.BACKEND has not been set.\n" "Hint: Have you called " "aiida.load_dbenv?") else: raise ConfigurationError("Unknown settings.BACKEND: {}".format( BACKEND)) return resource_name for k, v in schema.iteritems(): # Add custom fields to the column dictionaries if 'fields' in self.custom_schema: if k in self.custom_schema['fields'].keys(): schema[k].update(self.custom_schema['fields'][k]) # Convert python types values into strings schema[k]['type'] = str(schema[k]['type'])[7:-2] # Construct the 'related resource' field from the 'related_table' # field if v['is_foreign_key'] == True: schema[k]['related_resource'] = table2resource( schema[k].pop('related_table')) return dict(columns=schema) def init_qb(self): """ Initialize query builder object by means of _query_help """ self.qb.__init__(**self._query_help) self._is_qb_initialized = True def count(self): """ Count the number of rows returned by the query and set total_count """ if self._is_qb_initialized: self._total_count = self.qb.count() else: raise InvalidOperation("query builder object has not been " "initialized.") # def caching_method(self): # """ # class method for caching. It is a wrapper of the # flask_cache memoize # method. To be used as a decorator # :return: the flask_cache memoize method with the timeout kwarg # corrispondent to the class # """ # return cache.memoize() # # @cache.memoize(timeout=CACHING_TIMEOUTS[self.__label__]) def get_total_count(self): """ Returns the number of rows of the query :return: total_count """ ## Count the results if needed if not self._total_count: self.count() return self._total_count def set_filters(self, filters={}): """ Add filters in query_help. :param filters: it is a dictionary where keys are the tag names given in the path in query_help and their values are the dictionary of filters want to add for that tag name. Format for the Filters dictionary: filters = { "tag1" : {k1:v1, k2:v2}, "tag2" : {k1:v1, k2:v2}, } :return: query_help dict including filters if any. """ if isinstance(filters, dict): if len(filters) > 0: for tag, tag_filters in filters.iteritems(): if len(tag_filters) > 0 and isinstance(tag_filters, dict): self._query_help["filters"][tag] = {} for filter_key, filter_value in tag_filters.iteritems(): if filter_key == "pk": filter_key = pk_dbsynonym self._query_help["filters"][tag][filter_key] \ = filter_value else: raise InputValidationError("Pass data in dictionary format where " "keys are the tag names given in the " "path in query_help and and their values" " are the dictionary of filters want " "to add for that tag name.") def get_default_projections(self): """ method to get default projections of the node :return: self._default_projections """ return self._default_projections def set_default_projections(self): """ It calls the set_projections() methods internally to add the default projections in query_help :return: None """ self.set_projections({self.__label__: self._default_projections}) def set_projections(self, projections): """ add the projections in query_help :param projections: it is a dictionary where keys are the tag names given in the path in query_help and values are the list of the names you want to project in the final output :return: updated query_help with projections """ if isinstance(projections, dict): if len(projections) > 0: for project_key, project_list in projections.iteritems(): self._query_help["project"][project_key] = project_list else: raise InputValidationError("Pass data in dictionary format where " "keys are the tag names given in the " "path in query_help and values are the " "list of the names you want to project " "in the final output") def set_order(self, orders): """ Add order_by clause in query_help :param orders: dictionary of orders you want to apply on final results :return: None or exception if any. """ ## Validate input if type(orders) is not dict: raise InputValidationError("orders has to be a dictionary" "compatible with the 'order_by' section" "of the query_help") ## Auxiliary_function to get the ordering cryterion def def_order(columns): """ Takes a list of signed column names ex. ['id', '-ctime', '+mtime'] and transforms it in a order_by compatible dictionary :param columns: (list of strings) :return: a dictionary """ order_dict = {} for column in columns: if column[0] == '-': order_dict[column[1:]] = 'desc' elif column[0] == '+': order_dict[column[1:]] = 'asc' else: order_dict[column] = 'asc' if order_dict.has_key('pk'): order_dict[pk_dbsynonym] = order_dict.pop('pk') return order_dict ## Assign orderby field query_help for tag, columns in orders.iteritems(): self._query_help['order_by'][tag] = def_order(columns) def set_query(self, filters=None, orders=None, projections=None, pk=None): """ Adds filters, default projections, order specs to the query_help, and initializes the qb object :param filters: dictionary with the filters :param orders: dictionary with the order for each tag :param pk (integer): pk of a specific node """ tagged_filters = {} ## Check if filters are well defined and construct an ad-hoc filter # for pk_query if pk is not None: self._is_pk_query = True if self._result_type == self.__label__ and len(filters) > 0: raise RestInputValidationError("selecting a specific pk does " "not " "allow to specify filters") elif not self._check_pk_validity(pk): raise RestValidationError( "either the selected pk does not exist " "or the corresponding object is not of " "type aiida.orm.{}".format(self._aiida_type)) else: tagged_filters[self.__label__] = {'id': {'==': pk}} if self._result_type is not self.__label__: tagged_filters[self._result_type] = filters else: tagged_filters[self.__label__] = filters ## Add filters self.set_filters(tagged_filters) ## Add projections if projections is None: self.set_default_projections() else: tagged_projections = {self._result_type: projections} self.set_projections(tagged_projections) ##Add order_by if orders is not None: tagged_orders = {self._result_type: orders} self.set_order(tagged_orders) ## Initialize the query_object self.init_qb() def get_query_help(self): """ :return: return QB json dictionary """ return self._query_help def set_limit_offset(self, limit=None, offset=None): """ sets limits and offset directly to the query_builder object :param limit: :param offset: :return: """ ## mandatory params # none ## non-mandatory params if limit is not None: try: limit = int(limit) except ValueError: raise InputValidationError("Limit value must be an integer") if limit > self.LIMIT_DEFAULT: raise RestValidationError("Limit and perpage cannot be bigger " "than {}".format(self.LIMIT_DEFAULT)) else: limit = self.LIMIT_DEFAULT if offset is not None: try: offset = int(offset) except ValueError: raise InputValidationError("Offset value must be an " "integer") if self._is_qb_initialized: if limit is not None: self.qb.limit(limit) else: pass if offset is not None: self.qb.offset(offset) else: pass else: raise InvalidOperation("query builder object has not been " "initialized.") def get_formatted_result(self, label): """ Runs the query and retrieves results tagged as "label" :param label (string): the tag of the results to be extracted out of the query rows. :return: a list of the query results """ if not self._is_qb_initialized: raise InvalidOperation("query builder object has not been " "initialized.") results = [] if self._total_count > 0: results = [res[label] for res in self.qb.dict()] # TODO think how to make it less hardcoded if self._result_type == 'input_of': return {'inputs': results} elif self._result_type == 'output_of': return {'outputs': results} else: return {self.__label__: results} def get_results(self): """ Returns either list of nodes or details of single node from database :return: either list of nodes or details of single node from database """ ## Check whether the querybuilder object has been initialized if not self._is_qb_initialized: raise InvalidOperation("query builder object has not been " "initialized.") ## Count the total number of rows returned by the query (if not # already done) if self._total_count is None: self.count() ## Retrieve data data = self.get_formatted_result(self._result_type) return data def _check_pk_validity(self, pk): """ Checks whether a pk corresponds to an object of the expected type, whenever type is a valid column of the database (ex. for nodes, but not for users)_ :param pk: (integer) ok to check :return: True or False """ # The logic could be to load the node or to use querybuilder. Let's # do with qb for consistency, although it would be easier to do it # with load_node query_help_base = { 'path': [ { 'type': self._qb_type, 'label': self.__label__, }, ], 'filters': { self.__label__: { 'id': {'==': pk} } } } qb_base = QueryBuilder(**query_help_base) return qb_base.count() == 1