diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5741684242954b0e9b23f4d2c40ca862f17a9570..1db4c0394de48cbd82d5abbe4d7c581abcb9b277 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,6 +19,7 @@ # along with this program. If not, see <https://www.gnu.org/licenses/>. variables: + DEPLOY_REF: dev CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/caosdb-pylib/testenv:latest # When using dind, it's wise to use the overlayfs driver for # improved performance. @@ -63,10 +64,11 @@ trigger_build: script: - /usr/bin/curl -X POST -F token=$DEPLOY_TRIGGER_TOKEN + -F "variables[F_BRANCH]=$CI_COMMIT_REF_NAME" -F "variables[PYLIB]=$CI_COMMIT_REF_NAME" -F "variables[TriggerdBy]=PYLIB" -F "variables[TriggerdByHash]=$CI_COMMIT_SHORT_SHA" - -F ref=dev https://gitlab.indiscale.com/api/v4/projects/14/trigger/pipeline + -F ref=$DEPLOY_REF https://gitlab.indiscale.com/api/v4/projects/14/trigger/pipeline # Build a docker image in which tests for this repository can run build-testenv: diff --git a/CHANGELOG.md b/CHANGELOG.md index aa52d2b98fef19a8b1daffde3fb76e9107b6fa13..6428cc07799338f8b17fa3ce83332eec5e0fe9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* `[Entity|Container].get_property_values` for deeply nested references, e.g. + from results of SELECT queries. * two new `password_method`s for the `pycaosdb.ini` and the `configure_connection` function: `unauthenticated` for staying unauthenticated (and using the anonymous user) and `auth_token`. If diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 0134deef6ac071f044e261947a9dfaa0594d1d33..be6dfdcfd8ce5c90929785a3649da9b745868861 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -5,6 +5,8 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -46,10 +48,10 @@ from caosdb.connection.connection import get_connection from caosdb.connection.encode import MultipartParam, multipart_encode from caosdb.exceptions import (AmbiguityException, AuthorizationException, CaosDBException, ConnectionException, - ConsistencyError, - EntityDoesNotExistError, EntityError, - EntityHasNoDatatypeError, TransactionError, - UniqueNamesError, UnqualifiedParentsError, + ConsistencyError, EntityDoesNotExistError, + EntityError, EntityHasNoDatatypeError, + TransactionError, UniqueNamesError, + UnqualifiedParentsError, UnqualifiedPropertiesError, URITooLongException) from lxml import etree @@ -368,7 +370,7 @@ class Entity(object): del copy_kwargs['importance'] del copy_kwargs['inheritance'] - new_property = Property(value=value, **copy_kwargs) + new_property = Property(**copy_kwargs) if abstract_property is not None: new_property._wrap(property) @@ -378,6 +380,8 @@ class Entity(object): if new_property.datatype is None and isinstance( property, (RecordType, Record, File)): new_property.datatype = property + new_property.value = value + self.properties.append( property=new_property, importance=( kwargs['importance'] if 'importance' in kwargs else None), inheritance=( @@ -606,6 +610,103 @@ class Entity(object): return None + def _get_value_for_selector(self, selector): + """return the value described by the selector + + A selector is a list or a tuple of strings describing a path in an + entity tree with self as root. The last selector may be a special one + like unit or name. + + See also get_property_values() + """ + SPECIAL_SELECTORS = ["unit", "value", "description", "id", "name"] + + if not isinstance(selector, (tuple, list)): + selector = [selector] + + val = None + ref = self + + # there are some special selectors which can be applied to the + # final element; if such a special selector exists we split it + # from the list + + if selector[-1].lower() in SPECIAL_SELECTORS: + special_selector = selector[-1] + selector = selector[:-1] + else: + special_selector = None + + # iterating through the entity tree according to the selector + for subselector in selector: + # selector does not match the structure, we cannot get a + # property of non-entity + + if not isinstance(ref, Entity): + return None + + prop = ref.get_property(subselector) + + # selector does not match the structure, we did not get a + # property + if prop is None: + return None + + # if the property is a reference, we are interested in the + # corresponding entities attributes + if isinstance(prop.value, Entity): + ref = prop.value + + # otherwise in the attributes of the property + else: + ref = prop + + # if we saved a special selector before, apply it + if special_selector is None: + return prop.value + else: + return getattr(ref, special_selector.lower()) + + def get_property_values(self, *selectors): + """ Return a tuple with the values described by the given selectors. + + This represents an entity's properties as if it was a row of a table + with the given columns. + + If the elements of the selectors parameter are tuples, they will return + the properties of the referenced entity, if present. E.g. ("window", + "height") will return the value of the height property of the + referenced window entity. + + The tuple's values correspond to the order of selectors parameter. + + The tuple contains None for all values that are not available in the + entity. That does not necessarily mean, that the values are not stored + in the database (e.g. if a single entity was retrieved without + referenced entities). + + Parameters + ---------- + *selectors : str or tuple of str + Each selector is a list or tuple of property names, e.g. `"height", + "width"`. + + Returns + ------- + row : tuple + A row-like representation of the entity's properties. + """ + row = tuple() + + for selector in selectors: + val = self._get_value_for_selector(selector) + + if isinstance(val, Entity): + val = val.id if val.id is not None else val.name + row += (val,) + + return row + def get_messages(self): """Get all messages of this entity. @@ -834,17 +935,27 @@ class Entity(object): entity.add_message(child) elif child is None or hasattr(child, "encode"): vals.append(child) + elif isinstance(child, Entity): + vals.append(child) else: raise TypeError( 'Child was neither a Property, nor a Parent, nor a Message.\ Was ' + str(type(child))) - # parse VALUE + # add VALUE + value = None + if len(vals): # The value[s] have been inside a <Value> tag. - entity.value = vals + value = vals elif elem.text is not None and elem.text.strip() != "": - entity.value = elem.text.strip() + value = elem.text.strip() + + try: + entity.value = value + except ValueError: + # circumvent the parsing. + entity.__value = value return entity @@ -993,12 +1104,16 @@ class Entity(object): def _parse_value(datatype, value): if value is None: return value + if datatype is None: return value + if datatype == DOUBLE: return float(value) + if datatype == INTEGER: return int(str(value)) + if datatype == BOOLEAN: if str(value).lower() == "true": return True @@ -1006,14 +1121,17 @@ def _parse_value(datatype, value): return False else: raise ValueError("Boolean value was {}.".format(value)) + if datatype in [DATETIME, TEXT]: if isinstance(value, str): return value # deal with collections + if isinstance(datatype, str): matcher = re.compile(r"^(?P<col>[^<]+)<(?P<dt>[^>]+)>$") m = matcher.match(datatype) + if m: col = m.group("col") dt = m.group("dt") @@ -1034,14 +1152,18 @@ def _parse_value(datatype, value): # This is for a special case, where the xml parser could not differentiate # between single values and lists with one element. As + if hasattr(value, "__len__") and len(value) == 1: return _parse_value(datatype, value[0]) # deal with references + if isinstance(value, Entity): return value + if isinstance(value, str) and "@" in value: # probably this is a versioned reference + return str(value) else: # for unversioned references @@ -1049,6 +1171,7 @@ def _parse_value(datatype, value): return int(value) except ValueError: # reference via name + return str(value) @@ -3115,6 +3238,43 @@ class Container(list): return self + def get_property_values(self, *selectors): + """ Return a list of tuples with values of the given selectors. + + I.e. a tabular representation of the container's content. + + If the elements of the selectors parameter are tuples, they will return + the properties of the referenced entity, if present. E.g. ("window", + "height") will return the value of the height property of the + referenced window entity. + + All tuples of the returned list have the same length as the selectors + parameter and the ordering of the tuple's values correspond to the + order of the parameter as well. + + The tuple contains None for all values that are not available in the + entity. That does not necessarily mean, that the values are not stored + in the database (e.g. if a single entity was retrieved without + referenced entities). + + Parameters + ---------- + *selectors : str or tuple of str + Each selector is a list or tuple of property names, e.g. `"height", + "width"`. + + Returns + ------- + table : list of tuples + A tabular representation of the container's content. + """ + table = [] + + for e in self: + table.append(e.get_property_values(*selectors)) + + return table + def sync_global_acl(): c = get_connection() @@ -3546,6 +3706,7 @@ class Info(): http_response = c.retrieve(["Info"]) except ConnectionException as conn_e: print(conn_e) + return xml = etree.fromstring(http_response.read()) @@ -3663,6 +3824,7 @@ def _parse_single_xml_element(elem): return "" elif elem.text is None or elem.text.strip() == "": return None + return str(elem.text.strip()) elif elem.tag.lower() == "querytemplate": return QueryTemplate._from_xml(elem) diff --git a/unittests/test_container.py b/unittests/test_container.py new file mode 100644 index 0000000000000000000000000000000000000000..b34055372fc83a5608ffcf54423a601001add12b --- /dev/null +++ b/unittests/test_container.py @@ -0,0 +1,79 @@ + +# -*- encoding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""Tests for the Container class.""" +from __future__ import absolute_import + +import caosdb as c + + +def test_get_property_values(): + rt_house = c.RecordType("House") + rt_window = c.RecordType("Window") + rt_owner = c.RecordType("Owner") + p_height = c.Property("Height", datatype=c.DOUBLE) + + window = c.Record().add_parent(rt_window) + window.id = 1001 + window.add_property(p_height, 20.5, unit="m") + + owner = c.Record("The Queen").add_parent(rt_owner) + + house = c.Record("Buckingham Palace") + house.add_parent(rt_house) + house.add_property(rt_owner, owner) + house.add_property(rt_window, window) + house.add_property(p_height, 40.2, unit="ft") + + container = c.Container() + container.extend([ + house, + owner + ]) + + assert getattr(house.get_property(p_height), "unit") == "ft" + assert getattr(window.get_property(p_height), "unit") == "m" + + table = container.get_property_values("naME", + "height", + ("height", "unit"), + "window", + ("window", "non-existing"), + ("window", "non-existing", "unit"), + ("window", "unit"), + ("window", "heiGHT"), + ("window", "heiGHT", "value"), + ("window", "heiGHT", "unit"), + "owner", + ) + assert len(table) == 2 + house_row = table[0] + assert house_row == (house.name, 40.2, "ft", window.id, None, None, None, 20.5, 20.5, "m", owner.name) + + owner_row = table[1] + assert owner_row == (owner.name, None, None, None, None, None, None, None, None, None, None) + + assert container.get_property_values("non-existing") == [(None,), (None,)] + assert container.get_property_values("name") == [(house.name,), + (owner.name,)]