diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2d509a28c93722ffa1bcdf51391fab2455b086..8c9ed3081d083c1a78c14b590cb4b0e716478e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* `Entity.remove_value_from_property` function that removes a given value from a + property and optionally removes the property if it is empty afterwards. + ### Changed ### ### Deprecated ### @@ -47,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.11.2] - 2023-03-14 ## ### Fixed ### -- root logger is no longer used to create warnings. Fixes undesired output in +- root logger is no longer used to create warnings. Fixes undesired output in stderr ## [0.11.1] - 2023-03-07 ## diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 9ba54c49d2d4cd776dc2263b850cc095c65fea60..758252d1ee6b7136b3396bbb15693b4ef1816207 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- # -# ** header v3.0 # This file is a part of the CaosDB Project. # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen -# Copyright (C) 2020-2022 Indiscale GmbH <info@indiscale.com> -# Copyright (C) 2020 Florian Spreckelsen <f.spreckelsen@indiscale.com> +# Copyright (C) 2020-2023 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020-2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020-2022 Timm Fitschen <t.fitschen@indiscale.com> # # This program is free software: you can redistribute it and/or modify @@ -22,7 +21,6 @@ # 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 # """ @@ -47,6 +45,7 @@ from os import listdir from os.path import isdir from random import randint from tempfile import NamedTemporaryFile +from typing import Any, Optional from warnings import warn from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, @@ -453,6 +452,66 @@ class Entity: return self + def remove_value_from_property(self, property_name: str, value: Any, + remove_if_empty_afterwards: Optional[bool] = True): + """Remove a value from a property given by name. + + Do nothing if this entity does not have a property of this + ``property_name`` or if the property value is different of the given + ``value``. By default, the property is removed from this entity if it + becomes empty (i.e., value=None) through removal of the value. This + behavior can be changed by setting ``remove_if_empty_afterwards`` to + ``False`` in which case the property remains. + + Notes + ----- + If the property value is a list and the value to be removed occurs more + than once in this list, only its first occurrance is deleted (similar + to the behavior of Python's ``list.remove()``.) + + If the property was empty (prop.value == None) before, the property is + not removed afterwards even if ``remove_if_empty_afterwards`` is set to + ``True``. Rationale: the property being empty is not an effect of + calling this function. + + Parameters + ---------- + property_name : str + Name of the property from which the ``value`` will be removed. + + value + Value that is to be removed. + + remove_if_empty_afterwards : bool, optional + Whether the property shall be removed from this entity if it is + emptied by removing the ``value``. Default is ``True``. + + Returns + ------- + self + This entity. + + """ + + if self.get_property(property_name) is None: + return self + if self.get_property(property_name).value is None: + remove_if_empty_afterwards = False + empty_afterwards = False + if isinstance(self.get_property(property_name).value, list): + if value in self.get_property(property_name).value: + self.get_property(property_name).value.remove(value) + if self.get_property(property_name).value == []: + self.get_property(property_name).value = None + empty_afterwards = True + elif self.get_property(property_name).value == value: + self.get_property(property_name).value = None + empty_afterwards = True + if remove_if_empty_afterwards and empty_afterwards: + self.remove_property(property_name) + + return self + def remove_parent(self, parent): self.parents.remove(parent) diff --git a/unittests/test_property.py b/unittests/test_property.py index 7c756117765e510587c00d818e39fb3945d44c53..84f89b5a959192d7831e1bb3eab3a441912afe7e 100644 --- a/unittests/test_property.py +++ b/unittests/test_property.py @@ -1,11 +1,11 @@ # -*- encoding: utf-8 -*- # -# ** header v3.0 # This file is a part of the CaosDB Project. # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen -# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 - 2023 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> # # This program is free software: you can redistribute it and/or modify @@ -21,8 +21,6 @@ # 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 Property class.""" import os @@ -138,3 +136,87 @@ def test_is_reference(): # restore retrieve function with original Entity.retrieve = real_retrieve + + +def test_remove_value_from_property(): + + rec = Record() + names_values_dtypes = [ + ("testListProp1", [1, 2, 3], db.LIST(db.INTEGER)), + ("testListProp2", ["a", "b", "a"], db.LIST(db.TEXT)), + ("testScalarProp1", "bla", db.TEXT), + ("testScalarProp2", False, db.BOOLEAN), + ("testEmptyProp", None, db.REFERENCE), + ("testNoneListProp", [None, None], db.LIST(db.REFERENCE)), + ] + for name, value, dtype in names_values_dtypes: + rec.add_property(name=name, value=value, datatype=dtype) + + # property doesn't exist, so do nothing + returned = rec.remove_value_from_property("nonexisting", "some_value") + assert returned is rec + for name, value, dtype in names_values_dtypes: + assert rec.get_property(name).value == value + assert rec.get_property(name).datatype == dtype + + # value doesn't exist so nothing changes either + rec.remove_value_from_property("testListProp1", 0) + assert rec.get_property("testListProp1").value == [1, 2, 3] + assert rec.get_property("testListProp1").datatype == db.LIST(db.INTEGER) + + returned = rec.remove_value_from_property("testScalarProp2", True) + assert returned is rec + assert rec.get_property("testScalarProp2").value is False + assert rec.get_property("testScalarProp2").datatype == db.BOOLEAN + + # Simple removals from lists without emptying them + rec.remove_value_from_property("testListProp1", 1) + assert rec.get_property("testListProp1").value == [2, 3] + + rec.remove_value_from_property("testListProp1", 2) + assert rec.get_property("testListProp1").value == [3] + + # similarly to Python's `list.remove()`, only remove first occurrance + rec.remove_value_from_property("testListProp2", "a") + assert rec.get_property("testListProp2").value == ["b", "a"] + + # default is to remove an empty property: + rec.remove_value_from_property("testListProp1", 3) + assert rec.get_property("testListProp1") is None + + rec.remove_value_from_property("testScalarProp1", "bla") + assert rec.get_property("testScalarProp1") is None + + # don't remove if `remove_if_empty_afterwards=False` + rec.remove_value_from_property("testListProp2", "b") + rec.remove_value_from_property("testListProp2", "a", remove_if_empty_afterwards=False) + assert rec.get_property("testListProp2") is not None + assert rec.get_property("testListProp2").value is None + assert rec.get_property("testListProp2").datatype == db.LIST(db.TEXT) + + rec.remove_value_from_property("testScalarProp2", False, remove_if_empty_afterwards=False) + assert rec.get_property("testScalarProp2") is not None + assert rec.get_property("testScalarProp2").value is None + assert rec.get_property("testScalarProp2").datatype == db.BOOLEAN + + # Special case of an already empty property: It is not empty because a value + # was removed by `remove_value_from_property` but never had a value in the + # first place. So even `remove_if_empty_afterwards=True` should not lead to + # its removal. + rec.remove_value_from_property("testEmptyProp", 1234, remove_if_empty_afterwards=True) + assert rec.get_property("testEmptyProp") is not None + assert rec.get_property("testEmptyProp").value is None + assert rec.get_property("testEmptyProp").datatype == db.REFERENCE + + # Corner case of corner case: remove with `value=None` and + # `remove_if_empty_afterwards=True` keeps the empty property. + rec.remove_value_from_property("testEmptyProp", None, remove_if_empty_afterwards=True) + assert rec.get_property("testEmptyProp") is not None + assert rec.get_property("testEmptyProp").value is None + assert rec.get_property("testEmptyProp").datatype == db.REFERENCE + + # Remove `None` from list `[None, None]` + rec.remove_value_from_property("testNoneListProp", None, remove_if_empty_afterwards=True) + assert rec.get_property("testNoneListProp") is not None + assert rec.get_property("testNoneListProp").value == [None] + assert rec.get_property("testNoneListProp").datatype == db.LIST(db.REFERENCE)