diff --git a/CHANGELOG.md b/CHANGELOG.md index 33950b48f82425678c4770c42f46ec61f395ebef..57cf7f5ee5c714500dc006ea37f7fbd5b58baff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* Added examples for complex data models to documentation +* extended apiutils with `resolve_reference(Property)` +* is_reference function for Properties + ### Changed ### ### Deprecated ### +* `id_query(ids)` in apiutils + ### Removed ### ### Fixed ### @@ -20,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Unintuitive behavior of `Entity.role` after a `Entity(id).retrieve()` where the correct role is present now. * #53 Documentation of inheritance +* #38 Dependencies in chunk-deletion of containers ### Security ### diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index 73074efc3057e0548c5abfd56ef3cf1ac9e9bf47..9192289a0b6518a2d6ec3abcdeca9d1d2c063d6d 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -31,6 +31,7 @@ Some simplified functions for generation of records etc. import sys import tempfile from collections.abc import Iterable +import warnings from subprocess import call from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, @@ -86,9 +87,19 @@ def new_record(record_type, name=None, description=None, def id_query(ids): - q = "FIND Entity with " + " OR ".join(["id={}".format(id) for id in ids]) + warnings.warn("Please use 'create_id_query', which only creates" + "the string.", DeprecationWarning) - return execute_query(q) + return execute_query(create_id_query(ids)) + + +def create_id_query(ids): + return "FIND ENTITY WITH " + " OR ".join( + ["ID={}".format(id) for id in ids]) + + +def retrieve_entity_with_id(eid): + return execute_query("FIND ENTITY WITH ID={}".format(eid), unique=True) def retrieve_entities_with_ids(entities): @@ -96,7 +107,9 @@ def retrieve_entities_with_ids(entities): step = 20 for i in range(len(entities)//step+1): - collection.extend(id_query(entities[i*step:(i+1)*step])) + collection.extend( + execute_query( + create_id_query(entities[i*step:(i+1)*step]))) return collection @@ -707,3 +720,28 @@ def _apply_to_ids_of_entity(entity, func): else: if prop.value is not None: prop.value = func(prop.value) + + +def resolve_reference(prop: Property): + """resolves the value of a reference property + + The integer value is replaced with the entity object. + If the property is not a reference, then the function returns without + change. + """ + + if not prop.is_reference(server_retrieval=True): + return + + if isinstance(prop.value, list): + referenced = [] + + for val in prop.value: + if isinstance(val, int): + referenced.append(retrieve_entity_with_id(val)) + else: + referenced.append(val) + prop.value = referenced + else: + if isinstance(prop.value, int): + prop.value = retrieve_entity_with_id(prop.value) diff --git a/src/caosdb/common/datatype.py b/src/caosdb/common/datatype.py index eb8c1e4e0088f1924940a104ec3916b9d5d40f99..5434f5b6556a13f65754eacc66cb32231366e5b3 100644 --- a/src/caosdb/common/datatype.py +++ b/src/caosdb/common/datatype.py @@ -45,6 +45,8 @@ def LIST(datatype): def get_list_datatype(datatype): """ returns the datatype of the elements in the list """ + if not isinstance(datatype, str): + return None match = re.match("LIST(<|<)(?P<datatype>.*)(>|>)", datatype) if match is not None: @@ -66,6 +68,10 @@ def is_reference(datatype): RecordTypes """ + if datatype is None: + raise ValueError("Cannot decide whether datatype is reference if None" + " is supplied") + if datatype in [DOUBLE, BOOLEAN, INTEGER, TEXT, DATETIME]: return False elif is_list_datatype(datatype): diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index b054496d0255e6112b59f938d573a36bb5890b37..89397a6e5762fde5afebe76cb12d7dc5346b014c 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -31,6 +31,7 @@ from __future__ import print_function, unicode_literals import re import sys from builtins import str +from copy import deepcopy from functools import cmp_to_key from hashlib import sha512 from os import listdir @@ -40,26 +41,23 @@ from sys import hexversion from tempfile import NamedTemporaryFile from warnings import warn -from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT) -from caosdb.common.versioning import Version +from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, + is_list_datatype, is_reference) from caosdb.common.state import State from caosdb.common.utils import uuid, xml2str +from caosdb.common.versioning import Version from caosdb.configuration import get_config from caosdb.connection.connection import get_connection from caosdb.connection.encode import MultipartParam, multipart_encode -from caosdb.exceptions import (AmbiguousEntityError, - AuthorizationError, - CaosDBException, CaosDBConnectionError, - ConsistencyError, - EmptyUniqueQueryError, +from caosdb.exceptions import (AmbiguousEntityError, AuthorizationError, + CaosDBConnectionError, CaosDBException, + ConsistencyError, EmptyUniqueQueryError, EntityDoesNotExistError, EntityError, - EntityHasNoDatatypeError, - MismatchingEntitiesError, - QueryNotUniqueError, TransactionError, - UniqueNamesError, + EntityHasNoDatatypeError, HTTPURITooLongError, + MismatchingEntitiesError, QueryNotUniqueError, + TransactionError, UniqueNamesError, UnqualifiedParentsError, - UnqualifiedPropertiesError, - HTTPURITooLongError) + UnqualifiedPropertiesError) from lxml import etree _ENTITY_URI_SEGMENT = "Entity" @@ -580,6 +578,7 @@ class Entity(object): entity, or name. """ + if isinstance(key, int): for p in self.parents: if p.id is not None and int(p.id) == int(key): @@ -588,14 +587,17 @@ class Entity(object): if key.id is not None: # first try by id found = self.get_parent(int(key.id)) + if found is not None: return found # otherwise by name + return self.get_parent(key.name) else: for p in self.parents: if (p.name is not None and str(p.name).lower() == str(key).lower()): + return p return None @@ -680,6 +682,7 @@ class Entity(object): 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 @@ -691,11 +694,13 @@ class Entity(object): # 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 @@ -704,6 +709,7 @@ class Entity(object): ref = prop # if we saved a special selector before, apply it + if special_selector is None: return prop.value else: @@ -837,6 +843,7 @@ class Entity(object): assert isinstance(xml, etree._Element) # unwrap wrapped entity + if self._wrapped_entity is not None: xml = self._wrapped_entity.to_xml(xml, add_properties) @@ -1078,6 +1085,7 @@ class Entity(object): if len(c) == 1: c[0].messages.extend(c.messages) + return c[0] raise QueryNotUniqueError("This retrieval was not unique!!!") @@ -1502,6 +1510,42 @@ class Property(Entity): return super(Property, self).to_xml(xml, add_properties) + def is_reference(self, server_retrieval=False): + """Returns whether this Property is a reference + + Parameters + ---------- + server_retrieval : bool, optional + If True and the datatype is not set, the Property is retrieved from the server, by default False + + Returns + ------- + bool, NoneType + Returns whether this Property is a reference or None if a server call is needed to + check correctly, but server_retrieval is set to False. + + """ + + if self.datatype is None: + + if not self.is_valid(): + # this is a workaround to prevent side effects + # since retrieve currently changes the object + + if server_retrieval: + tmp_prop = deepcopy(self) + tmp_prop.retrieve() + + return tmp_prop.is_reference() + else: + return None + else: + # a valid property without datatype has to be an RT + + return True + else: + return is_reference(self.datatype) + class Message(object): @@ -2150,6 +2194,7 @@ class _Messages(dict): else: raise TypeError( "('description', 'body'), ('body'), or 'body' expected.") + if isinstance(value, Message): body = value.body description = value.description @@ -2321,6 +2366,7 @@ def _deletion_sync(e_local, e_remote): except KeyError: # deletion info wasn't there e_local.messages = e_remote.messages + return _basic_sync(e_local, e_remote) @@ -2513,6 +2559,7 @@ class Container(list): tmpid = 0 # users might already have specified some tmpids. -> look for smallest. + for e in self: tmpid = min(tmpid, Container._get_smallest_tmpid(e)) tmpid -= 1 @@ -2732,6 +2779,7 @@ class Container(list): used_remote_entities = [] # match by cuid + for local_entity in self: sync_dict[local_entity] = None @@ -2760,6 +2808,7 @@ class Container(list): raise MismatchingEntitiesError(msg) # match by id + for local_entity in self: if sync_dict[local_entity] is None and local_entity.id is not None: sync_remote_entities = [] @@ -2784,6 +2833,7 @@ class Container(list): raise MismatchingEntitiesError(msg) # match by path + for local_entity in self: if (sync_dict[local_entity] is None and local_entity.path is not None): @@ -2813,6 +2863,7 @@ class Container(list): raise MismatchingEntitiesError(msg) # match by name + for local_entity in self: if (sync_dict[local_entity] is None and local_entity.name is not None): @@ -2862,7 +2913,60 @@ class Container(list): return sync_dict - def delete(self, raise_exception_on_error=True, flags=None): + def _test_dependencies_in_container(self, container): + """This function returns those elements of a given container that are a dependency of another element of the same container. + + Args: + container (Container): a caosdb container + + Returns: + [set]: a set of unique elements that are a dependency of another element of `container` + """ + item_id = set() + is_parent = set() + is_property = set() + is_being_referenced = set() + dependent_parents = set() + dependent_properties = set() + dependent_references = set() + dependencies = set() + + for container_item in container: + item_id.add(container_item.id) + + for parents in container_item.get_parents(): + is_parent.add(parents.id) + + for references in container_item.get_properties(): + if is_reference(references.datatype): + # add only if it is a reference, not a property + + if isinstance(references.value, int): + is_being_referenced.add(references.value) + elif is_list_datatype(references.datatype): + for list_item in references.value: + if isinstance(list_item, int): + is_being_referenced.add(list_item) + else: + is_being_referenced.add(list_item.id) + else: + try: + is_being_referenced.add(references.value.id) + except AttributeError: + pass + + if hasattr(references, 'id'): + is_property.add(references.id) + + dependent_parents = item_id.intersection(is_parent) + dependent_properties = item_id.intersection(is_property) + dependent_references = item_id.intersection(is_being_referenced) + dependencies = dependent_parents.union(dependent_references) + dependencies = dependencies.union(dependent_properties) + + return dependencies + + def delete(self, raise_exception_on_error=True, flags=None, chunk_size=100): """Delete all entities in this container. Entities are identified via their id if present and via their @@ -2873,16 +2977,45 @@ class Container(list): this happens, none of them will be deleted. It occurs an error instead. """ - chunk_size = 100 item_count = len(self) # Split Container in 'chunk_size'-sized containers (if necessary) to avoid error 414 Request-URI Too Long + if item_count > chunk_size: + dependencies = self._test_dependencies_in_container(self) + ''' + If there are as many dependencies as entities in the container and it is larger than chunk_size it cannot be split and deleted. + This case cannot be handled at the moment. + ''' + + if len(dependencies) == item_count: + if raise_exception_on_error: + te = TransactionError( + msg="The container is too large and with too many dependencies within to be deleted.", + container=self) + raise te + + return self + + # items which have to be deleted later because of dependencies. + dependencies_delete = Container() + for i in range(0, int(item_count/chunk_size)+1): chunk = Container() + for j in range(i*chunk_size, min(item_count, (i+1)*chunk_size)): - chunk.append(self[j]) + if len(dependencies): + if self[j].id in dependencies: + dependencies_delete.append(self[j]) + else: + chunk.append(self[j]) + else: + chunk.append(self[j]) + if len(chunk): chunk.delete() + if len(dependencies_delete): + dependencies_delete.delete() + return self if len(self) == 0: @@ -3520,6 +3653,7 @@ class ACL(): result._priority_grants.update(self._priority_grants) result._priority_denials.update(other._priority_denials) result._priority_denials.update(self._priority_denials) + return result def __eq__(self, other): @@ -3810,6 +3944,7 @@ class Query(): connection = get_connection() flags = self.flags + if cache is False: flags["cache"] = "false" query_dict = dict(flags) @@ -3836,9 +3971,11 @@ class Query(): if len(cresp) > 1 and raise_exception_on_error: raise QueryNotUniqueError( "Query '{}' wasn't unique.".format(self.q)) + if len(cresp) == 0 and raise_exception_on_error: raise EmptyUniqueQueryError( "Query '{}' found no results.".format(self.q)) + if len(cresp) == 1: r = cresp[0] r.messages.extend(cresp.messages) @@ -3945,6 +4082,7 @@ class Info(): for e in xml: m = _parse_single_xml_element(e) + if isinstance(m, UserInfo): self.user_info = m else: @@ -4104,13 +4242,16 @@ def _evaluate_and_add_error(parent_error, ent): Parent error with new exception(s) attached to it. """ + if isinstance(ent, (Entity, QueryTemplate)): # Check all error messages found114 = False found116 = False + for err in ent.get_errors(): # Evaluate specific EntityErrors depending on the error # code + if err.code is not None: if int(err.code) == 101: # ent doesn't exist new_exc = EntityDoesNotExistError(entity=ent, @@ -4127,6 +4268,7 @@ def _evaluate_and_add_error(parent_error, ent): found114 = True new_exc = UnqualifiedPropertiesError(entity=ent, error=err) + for prop in ent.get_properties(): new_exc = _evaluate_and_add_error(new_exc, prop) @@ -4134,6 +4276,7 @@ def _evaluate_and_add_error(parent_error, ent): found116 = True new_exc = UnqualifiedParentsError(entity=ent, error=err) + for par in ent.get_parents(): new_exc = _evaluate_and_add_error(new_exc, par) @@ -4144,21 +4287,28 @@ def _evaluate_and_add_error(parent_error, ent): parent_error.add_error(new_exc) # Check for possible errors in parents and properties that # weren't detected up to here + if not found114: dummy_err = EntityError(entity=ent) + for prop in ent.get_properties(): dummy_err = _evaluate_and_add_error(dummy_err, prop) + if dummy_err.errors: parent_error.add_error(dummy_err) + if not found116: dummy_err = EntityError(entity=ent) + for par in ent.get_parents(): dummy_err = _evaluate_and_add_error(dummy_err, par) + if dummy_err.errors: parent_error.add_error(dummy_err) elif isinstance(ent, Container): parent_error.container = ent + if ent.get_errors() is not None: parent_error.code = ent.get_errors()[0].code # In the highly unusual case of more than one error @@ -4166,6 +4316,7 @@ def _evaluate_and_add_error(parent_error, ent): parent_error.msg = '\n'.join( [x.description for x in ent.get_errors()]) # Go through all container elements and add them: + for elt in ent: parent_error = _evaluate_and_add_error(parent_error, elt) @@ -4191,10 +4342,12 @@ def raise_errors(arg0): transaction_error = _evaluate_and_add_error(TransactionError(), arg0) # Raise if any error was found + if len(transaction_error.all_errors) > 0: raise transaction_error # Cover the special case of an empty container with error # message(s) (e.g. query syntax error) + if (transaction_error.container is not None and transaction_error.container.has_errors()): raise transaction_error diff --git a/src/caosdb/yamlapi.py b/src/caosdb/yamlapi.py index b2e527983cbbeca2ee71a63e87f487d24d7c0301..9a69a5276804727084af65c6568b22833e8be596 100644 --- a/src/caosdb/yamlapi.py +++ b/src/caosdb/yamlapi.py @@ -73,6 +73,14 @@ def dict_to_xml(d): def yaml_to_xml(yamlstr): + """Load a yaml document from yamlstr and converts it to XML. + + Parameters + ---------- + yamlstr : str + The string to load the yaml document from. + + """ return dict_to_xml(yaml.load(yamlstr)) diff --git a/src/doc/tutorials/complex_data_models.rst b/src/doc/tutorials/complex_data_models.rst new file mode 100644 index 0000000000000000000000000000000000000000..0fa868e78bb45a2905dc99392a3a28a9832d369e --- /dev/null +++ b/src/doc/tutorials/complex_data_models.rst @@ -0,0 +1,76 @@ +Complex Data Models +------------------- + +With CaosDB it is possible to create very complex data models. + +E.g. it is possible to add properties to properties to cover complex relations +in data management workflows. + +One example for a use case is meta data that is added to very specific properties of +datasets, e.g. data privacy information can be added to properties which themselves +could already be considered meta data of a dataset. + +The example below tries to cover some complex cases for data models: + +Examples +-------- + +.. code-block:: python3 + + import caosdb as db + + # Create two record types with descriptions: + rt1 = db.RecordType(name="TypeA", description="The first type") + rt2 = db.RecordType(name="TypeB", description="The second type") + + # Create a record using the first record type as parent: + r1 = db.Record(name="Test_R_1", description="A record") + r1.add_parent(rt1) + + # Create two files (the files named test.txt and testfile2.txt should exist in the + # current working directory: + f1 = db.File(name="Test file", path="/test.txt", file="test.txt") + f2 = db.File(name="Test file 2", path="/testfile2.txt", file="testfile2.txt") + + # Create two properties with different data types: + p1 = db.Property(name="TestFileProperty", datatype=db.FILE) + p2 = db.Property(name="TestDoubleProperty", datatype=db.DOUBLE, unit="m") + p3 = db.Property(name="TestIntegerProperty", datatype=db.INTEGER, unit="s") + + # Create a reference property that points to records of record type 2: + p4 = db.Property(name="TestReferenceProperty", datatype=rt2) + + # Create a complex record: + r2 = db.Record(name="Test_R_2", description="A second record") + r2.add_parent(rt2) + r2.add_property(rt1, value=r1) # this is a reference to the first record type + r2.add_property(p1, value=f1) # this is a reference to the first file + r2.add_property(p2, value=24.8) # this is a double property with a value + r2.add_property(p3, value=1) # this is an integer property with a value + + # Very complex part of the data model: + # Case 1: File added to another file + f2.add_property(p1, value=f1) # this adds a file property with value first file + # to the second file + + # Case 2: Property added to a property + p2.add_property(p3, value=27) # this adds an integer property with value 27 to the + # double property + + # Case 3: Reference property added to a property + # The property p2 now has two sub properties, one is pointing to + # record p2 which itself has the property p2, therefore this can be + # considered a loop in the data model. + p2.add_property(p4, value=r2) # this adds a reference property pointing to + # record 2 to the double property + + # Insert a container containing all the newly created entities: + c = db.Container().extend([rt1, rt2, r1, r2, f1, p1, p2, p3, f2, p4]) + c.insert() + + # Useful for testing: wait until the user presses a key + # Meanwhile have a look at the WebUI: You can e.g. query "FIND Test*" to view + # all the entities created here and see the relations and links between them. + b = input("Press any key to cleanup.") + # cleanup everything after the user presses any button. + c.delete() diff --git a/src/doc/tutorials/index.rst b/src/doc/tutorials/index.rst index 3889edb8f47e973cc7ae25c9134d75cfeab95f65..79068e9201498c87b2eb61b4ffbea0969845b404 100644 --- a/src/doc/tutorials/index.rst +++ b/src/doc/tutorials/index.rst @@ -15,4 +15,5 @@ advanced usage of the Python client. Data-Insertion errors data-model-interface - + complex_data_models + diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index c560b5e3c7c424b762bc8381c7cc9f42617288d0..264b4c880022e6fd135426864bf9c5084c047eca 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -29,7 +29,8 @@ import caosdb as db import pickle import tempfile -from caosdb.apiutils import apply_to_ids +from caosdb.apiutils import apply_to_ids, create_id_query, resolve_reference +import caosdb.apiutils from .test_property import testrecord @@ -62,3 +63,42 @@ def test_apply_to_ids(): assert rec.parents[0].id == -3456 assert rec.properties[0].id == -23345 assert rec.id == -23 + + +def test_id_query(): + ids = [1, 2, 3, 4, 5] + assert create_id_query(ids) == 'FIND ENTITY WITH ID=1 OR ID=2 OR ID=3 OR ID=4 OR ID=5' + + +def test_resolve_reference(): + original_retrieve_entity_with_id = caosdb.apiutils.retrieve_entity_with_id + caosdb.apiutils.retrieve_entity_with_id = lambda eid: db.Record(id=eid) + + prop = db.Property(id=1, datatype=db.REFERENCE, value=100) + prop.is_valid = lambda: True + items = [200, 300, 400] + prop_list = db.Property(datatype=db.LIST(db.REFERENCE), value=items) + prop_list2 = db.Property(datatype=db.LIST(db.REFERENCE), value=[db.Record(id=500)]) + resolve_reference(prop) + resolve_reference(prop_list) + resolve_reference(prop_list2) + assert prop.value.id == 100 + assert isinstance(prop.value, db.Entity) + + prop_list_ids = [] + for i in prop_list.value: + prop_list_ids.append(i.id) + assert isinstance(i, db.Entity) + assert prop_list_ids == items + + for i in prop_list2.value: + assert i.id == 500 + assert isinstance(i, db.Entity) + + no_reference = db.Property(id=5000, datatype=db.INTEGER, value=2) + resolve_reference(no_reference) + assert no_reference.value == 2 + assert no_reference.datatype is db.INTEGER + + # restore retrive_entity_with_id + caosdb.apiutils.retrieve_entity_with_id = original_retrieve_entity_with_id diff --git a/unittests/test_container.py b/unittests/test_container.py index b34055372fc83a5608ffcf54423a601001add12b..0ac4be44826825aa3302119c8bca08f335ab68d3 100644 --- a/unittests/test_container.py +++ b/unittests/test_container.py @@ -25,28 +25,28 @@ """Tests for the Container class.""" from __future__ import absolute_import -import caosdb as c +import caosdb as db 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) + rt_house = db.RecordType("House") + rt_window = db.RecordType("Window") + rt_owner = db.RecordType("Owner") + p_height = db.Property("Height", datatype=db.DOUBLE) - window = c.Record().add_parent(rt_window) + window = db.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) + owner = db.Record("The Queen").add_parent(rt_owner) - house = c.Record("Buckingham Palace") + house = db.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 = db.Container() container.extend([ house, owner @@ -77,3 +77,70 @@ def test_get_property_values(): assert container.get_property_values("non-existing") == [(None,), (None,)] assert container.get_property_values("name") == [(house.name,), (owner.name,)] + + +def test_container_dependencies_for_deletion(): + not_included_rt = 1000 + rt = db.RecordType("Just a RecordType") + rt.id = 1001 + rt_record_with_parent = db.RecordType("Records with parent") + rt_record_with_parent.id = 1005 + property_which_is_not_a_record = db.Property( + "Normal Property", datatype=db.DOUBLE, value=1006) + property_which_is_not_a_record.id = 1006 + property_which_shall_be_deleted = db.Property( + "Normal Property 2", datatype=db.DOUBLE, value=1006) + property_which_shall_be_deleted .id = 1007 + + record_without_dependencies = db.Record().add_parent(not_included_rt) + record_without_dependencies.id = 2003 + + record_referenced = db.Record().add_parent(not_included_rt) + record_referenced.id = 2002 + record_with_dependencies = db.Record().add_parent(not_included_rt) + record_with_dependencies.id = 2004 + record_with_dependencies.add_property(not_included_rt, + record_referenced, + datatype="not_included_rt") + + record_with_parent = db.Record().add_parent(rt_record_with_parent) + record_with_parent.id = 2005 + + record_with_property_which_is_not_a_record = db.Record( + ).add_parent(not_included_rt) + record_with_property_which_is_not_a_record.id = 2006 + record_with_property_which_is_not_a_record.add_property( + property_which_is_not_a_record) + record_with_property_which_is_not_a_record.add_property( + property_which_shall_be_deleted) + + container = db.Container() + container.extend([ + rt, + rt_record_with_parent, # 1005, dependency + record_without_dependencies, + property_which_shall_be_deleted, # 1007, dependency + record_referenced, # 2002, dependency + record_with_dependencies, + record_with_parent, + record_with_property_which_is_not_a_record + ]) + assert (db.Container()._test_dependencies_in_container(container) + == {2002, 1005, 1007}) + + +def test_container_dependencies_for_deletion_with_lists(): + not_included_rt = 1000 + + record_referenced = db.Record().add_parent(not_included_rt) + record_referenced.id = 2001 + + record_with_list = db.Record().add_parent(not_included_rt) + record_with_list.id = 2002 + record_with_list.add_property(not_included_rt, datatype=db.LIST( + not_included_rt), value=[record_referenced, 2003, 2004, 2005, 2006]) + + container = db.Container() + container.extend([record_with_list, record_referenced]) + + assert db.Container()._test_dependencies_in_container(container) == {2001} diff --git a/unittests/test_property.py b/unittests/test_property.py index 752ee01f0eafef14dbffd1e62c99d1c816c45d05..834b1be582c58c60f70331de9cb0d0d6414fd6c9 100644 --- a/unittests/test_property.py +++ b/unittests/test_property.py @@ -24,9 +24,10 @@ # ** end header # """Tests for the Property class.""" +import caosdb as db +from caosdb import Entity, Property, Record # pylint: disable=missing-docstring from lxml import etree -from caosdb import Entity, Property, Record parser = etree.XMLParser(remove_comments=True) testrecord = Record._from_xml(Record(), @@ -89,3 +90,47 @@ def test_get_property_with_entity(): def test_selected_reference_list(): assert len(testrecord.get_property("Conductor").value) == 1 assert isinstance(testrecord.get_property("Conductor").value[0], Entity) + + +def test_is_reference(): + PROPS = { + 10: db.INTEGER, + 20: db.REFERENCE, + 30: "SomeRT", + } + + def dummy_retrieve(self): + self.datatype = PROPS[self.id] + self.is_valid = lambda: True + # replace retrieve function by dummy + real_retrieve = Entity.retrieve + Entity.retrieve = dummy_retrieve + + p1 = Property(id=1, datatype=db.INTEGER) + p2 = Property(id=2, datatype=db.DOUBLE) + p3 = Property(id=3, datatype=db.TEXT) + p4 = Property(id=4, datatype=db.DATETIME) + p5 = Property(id=5, datatype=db.BOOLEAN) + p6 = Property(id=6, datatype=db.REFERENCE) + assert p1.is_reference() is False + assert p2.is_reference() is False + assert p3.is_reference() is False + assert p4.is_reference() is False + assert p5.is_reference() is False + assert p6.is_reference() is True + + p7 = Property(id=7) + p8 = Property(id=8, value=db.RecordType(id=1000)) + p8.is_valid = lambda: True + assert p7.is_reference() is None # cannot be resolved without calling a server + assert p8.is_reference() is True + + p10 = Property(id=10) + p20 = Property(id=20) + p30 = Property(id=30) + assert p10.is_reference(server_retrieval=True) is False + assert p20.is_reference(server_retrieval=True) is True + assert p30.is_reference(server_retrieval=True) is True + + # restore retrieve function with original + Entity.retrieve = real_retrieve