ソースを参照

Merge pull request #67 from scossu/development

alpha15
Stefano Cossu 7 年 前
コミット
1d914c16eb

+ 5 - 1
README.rst

@@ -1,7 +1,7 @@
 LAKEsuperior
 ============
 
-|build status| |docs|
+|build status| |docs| |pypi|
 
 LAKEsuperior is an alternative `Fedora
 Repository <http://fedorarepository.org>`__ implementation.
@@ -65,3 +65,7 @@ including installation instructions.
 .. |docs| image:: https://readthedocs.org/projects/lakesuperior/badge/
     :alt: Documentation Status
     :target: https://lakesuperior.readthedocs.io/en/latest/?badge=latest
+    
+.. |pypi| image:: https://badge.fury.io/py/lakesuperior.svg
+    :alt: PyPI Package
+    :target: https://badge.fury.io/py/lakesuperior

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.0.0a14
+1.0.0a15

+ 25 - 21
lakesuperior/api/resource.py

@@ -164,7 +164,7 @@ def get(uid, repr_options={}):
     - incl_children: include children URIs. Default: True.
     - embed_children: Embed full graph of all child resources. Default: False
     """
-    rsrc = LdpFactory.from_stored(uid, repr_options)
+    rsrc = LdpFactory.from_stored(uid, repr_opts=repr_options)
     # Load graph before leaving the transaction.
     rsrc.imr
 
@@ -292,7 +292,7 @@ def create_version(uid, ver_uid):
 
 
 @transaction(True)
-def delete(uid, soft=True):
+def delete(uid, soft=True, inbound=True):
     """
     Delete a resource.
 
@@ -306,27 +306,22 @@ def delete(uid, soft=True):
     inbound = True if refint else inbound
     repr_opts = {'incl_inbound' : True} if refint else {}
 
-    children = env.app_globals.rdfly.get_descendants(uid)
-
+    rsrc = LdpFactory.from_stored(uid, repr_opts, strict=soft)
     if soft:
-        rsrc = LdpFactory.from_stored(uid, repr_opts)
-        ret = rsrc.bury_rsrc(inbound)
-
-        for child_uri in children:
-            try:
-                child_rsrc = LdpFactory.from_stored(
-                    env.app_globals.rdfly.uri_to_uid(child_uri),
-                    repr_opts={'incl_children' : False})
-            except (TombstoneError, ResourceNotExistsError):
-                continue
-            child_rsrc.bury_rsrc(inbound, tstone_pointer=rsrc.uri)
+        return rsrc.bury(inbound)
     else:
-        ret = env.app_globals.rdfly.forget_rsrc(uid, inbound)
-        for child_uri in children:
-            child_uid = env.app_globals.rdfly.uri_to_uid(child_uri)
-            ret = env.app_globals.rdfly.forget_rsrc(child_uid, inbound)
+        return rsrc.forget(inbound)
+
+
+@transaction(True)
+def revert_to_version(uid, ver_uid):
+    """
+    Restore a resource to a previous version state.
 
-    return ret
+    :param str uid: Resource UID.
+    :param str ver_uid: Version UID.
+    """
+    return LdpFactory.from_stored(uid).revert_to_version(ver_uid)
 
 
 @transaction(True)
@@ -336,4 +331,13 @@ def resurrect(uid):
 
     :param str uid: Resource UID.
     """
-    return LdpFactory.from_stored(uid).resurrect_rsrc()
+    try:
+        rsrc = LdpFactory.from_stored(uid)
+    except TombstoneError as e:
+        if e.uid != uid:
+            raise
+        else:
+            return LdpFactory.from_stored(uid, strict=False).resurrect()
+    else:
+        raise InvalidResourceError(
+                uid, msg='Resource {} is not dead.'.format(uid))

+ 0 - 6
lakesuperior/dictionaries/namespaces.py

@@ -12,7 +12,6 @@ core_namespaces = {
     'dcterms' : rdflib.namespace.DCTERMS,
     'ebucore' : Namespace(
         'http://www.ebu.ch/metadata/ontologies/ebucore/ebucore#'),
-    #'fcrconfig' : Namespace('http://fedora.info/definitions/v4/config#'),
     'fcrepo' : Namespace('http://fedora.info/definitions/v4/repository#'),
     'fcadmin' : Namespace('info:fcsystem/graph/admin'),
     'fcres' : Namespace('info:fcres'),
@@ -22,15 +21,12 @@ core_namespaces = {
     'foaf': Namespace('http://xmlns.com/foaf/0.1/'),
     'iana' : Namespace('http://www.iana.org/assignments/relation/'),
     'ldp' : Namespace('http://www.w3.org/ns/ldp#'),
-    # This is used in the layout attribute router.
     'pcdm': Namespace('http://pcdm.org/models#'),
     'premis' : Namespace('http://www.loc.gov/premis/rdf/v1#'),
     'rdf' : rdflib.namespace.RDF,
     'rdfs' : rdflib.namespace.RDFS,
     'webac' : Namespace('http://www.w3.org/ns/auth/acl#'),
-    'xml' : Namespace('http://www.w3.org/XML/1998/namespace'),
     'xsd' : rdflib.namespace.XSD,
-    'xsi' : Namespace('http://www.w3.org/2001/XMLSchema-instance'),
 }
 
 ns_collection = core_namespaces.copy()
@@ -38,9 +34,7 @@ custom_ns = {pfx: Namespace(ns) for pfx, ns in config['namespaces'].items()}
 ns_collection.update(custom_ns)
 
 ns_mgr = NamespaceManager(Graph())
-ns_pfx_sparql = {}
 
 # Collection of prefixes in a dict.
 for ns,uri in ns_collection.items():
     ns_mgr.bind(ns, uri, override=False)
-    #ns_pfx_sparql[ns] = 'PREFIX {}: <{}>'.format(ns, uri)

+ 2 - 2
lakesuperior/endpoints/ldp.py

@@ -251,7 +251,7 @@ def post_resource(parent_uid):
     uri = g.tbox.uid_to_uri(uid)
     hdr = {'Location' : uri}
 
-    if mimetype and not is_rdf:
+    if mimetype and rdf_fmt is None:
         hdr['Link'] = '<{0}/fcr:metadata>; rel="describedby"; anchor="{0}"'\
                 .format(uri)
 
@@ -446,7 +446,7 @@ def patch_version(uid, ver_uid):
     :param str ver_uid: Version UID.
     """
     try:
-        LdpFactory.from_stored(uid).revert_to_version(ver_uid)
+        rsrc_api.revert_to_version(uid, rsrc_uid)
     except ResourceNotExistsError as e:
         return str(e), 404
     except InvalidResourceError as e:

+ 5 - 9
lakesuperior/model/ldp_factory.py

@@ -42,7 +42,7 @@ class LdpFactory:
 
 
     @staticmethod
-    def from_stored(uid, repr_opts={}, **kwargs):
+    def from_stored(uid, ver_label=None, repr_opts={}, strict=True, **kwargs):
         """
         Create an instance for retrieval purposes.
 
@@ -52,15 +52,11 @@ class LdpFactory:
 
         N.B. The resource must exist.
 
-        :param  uid: UID of the instance.
+        :param str uid: UID of the instance.
         """
-        #logger.info('Retrieving stored resource: {}'.format(uid))
-        imr_urn = nsc['fcres'][uid]
-
-        rsrc_meta = rdfly.get_metadata(uid)
-        #logger.debug('Extracted metadata: {}'.format(
-        #        pformat(set(rsrc_meta))))
-        rdf_types = set(rsrc_meta[imr_urn : RDF.type])
+        # This will blow up if strict is True and the resource is a tombstone.
+        rsrc_meta = rdfly.get_metadata(uid, strict=strict)
+        rdf_types = set(rsrc_meta[nsc['fcres'][uid] : RDF.type])
 
         if LDP_NR_TYPE in rdf_types:
             logger.info('Resource is a LDP-NR.')

+ 114 - 87
lakesuperior/model/ldpr.py

@@ -1,4 +1,5 @@
 import logging
+import re
 
 from abc import ABCMeta
 from collections import defaultdict
@@ -98,6 +99,30 @@ class Ldpr(metaclass=ABCMeta):
     }
     """Predicates to remove when a resource is replaced."""
 
+    _ignore_version_preds = {
+        nsc['fcrepo'].hasParent,
+        nsc['fcrepo'].hasVersions,
+        nsc['fcrepo'].hasVersion,
+        nsc['premis'].hasMessageDigest,
+        nsc['ldp'].contains,
+    }
+    """Predicates that don't get versioned."""
+
+    _ignore_version_types = {
+        nsc['fcrepo'].Binary,
+        nsc['fcrepo'].Container,
+        nsc['fcrepo'].Pairtree,
+        nsc['fcrepo'].Resource,
+        nsc['fcrepo'].Version,
+        nsc['ldp'].BasicContainer,
+        nsc['ldp'].Container,
+        nsc['ldp'].DirectContainer,
+        nsc['ldp'].Resource,
+        nsc['ldp'].RDFSource,
+        nsc['ldp'].NonRDFSource,
+    }
+    """RDF types that don't get versioned."""
+
 
     ## MAGIC METHODS ##
 
@@ -387,23 +412,25 @@ class Ldpr(metaclass=ABCMeta):
         return ev_type
 
 
-    def bury_rsrc(self, inbound, tstone_pointer=None):
+    def bury(self, inbound, tstone_pointer=None):
         """
         Delete a single resource and create a tombstone.
 
-        :param boolean inbound: Whether to delete the inbound relationships.
-        :param rdflib.URIRef tstone_pointer: If set to a URN, this creates a
+        :param bool inbound: Whether inbound relationships are
+            removed. If ``False``, resources will keep referring
+            to the deleted resource; their link will point to a tombstone
+            (which will raise a ``TombstoneError`` in the Python API or a
+            ``410 Gone`` in the LDP API).
+        :param rdflib.URIRef tstone_pointer: If set to a URI, this creates a
             pointer to the tombstone of the resource that used to contain the
             deleted resource. Otherwise the deleted resource becomes a
             tombstone.
         """
         logger.info('Burying resource {}'.format(self.uid))
-        # Create a backup snapshot for resurrection purposes.
-        self.create_rsrc_snapshot(uuid4())
-
+        # ldp:Resource is also used in rdfly.ask_rsrc_exists.
         remove_trp = {
-            trp for trp in self.imr
-            if trp[1] != nsc['fcrepo'].hasVersion}
+            (nsc['fcrepo'].uid, nsc['rdf'].type, nsc['ldp'].Resource)
+        }
 
         if tstone_pointer:
             add_trp = {
@@ -411,39 +438,89 @@ class Ldpr(metaclass=ABCMeta):
         else:
             add_trp = {
                 (self.uri, RDF.type, nsc['fcsystem'].Tombstone),
-                (self.uri, nsc['fcrepo'].created, thread_env.timestamp_term),
+                (self.uri, nsc['fcsystem'].buried, thread_env.timestamp_term),
             }
 
-        self.modify(RES_DELETED, remove_trp, add_trp)
-
+        # Bury descendants.
+        from lakesuperior.model.ldp_factory import LdpFactory
+        for desc_uri in rdfly.get_descendants(self.uid):
+            try:
+                desc_rsrc = LdpFactory.from_stored(
+                    env.app_globals.rdfly.uri_to_uid(desc_uri),
+                    repr_opts={'incl_children' : False})
+            except (TombstoneError, ResourceNotExistsError):
+                continue
+            desc_rsrc.bury(inbound, tstone_pointer=self.uri)
+
+        # Cut inbound relationships
         if inbound:
             for ib_rsrc_uri in self.imr.subjects(None, self.uri):
                 remove_trp = {(ib_rsrc_uri, None, self.uri)}
                 ib_rsrc = Ldpr(ib_rsrc_uri)
                 # To preserve inbound links in history, create a snapshot
-                ib_rsrc.create_rsrc_snapshot(uuid4())
+                ib_rsrc.create_version()
                 ib_rsrc.modify(RES_UPDATED, remove_trp)
 
+        self.modify(RES_DELETED, remove_trp, add_trp)
+
         return RES_DELETED
 
 
-    def forget_rsrc(self, inbound=True):
+    def forget(self, inbound=True):
         """
         Remove all traces of a resource and versions.
         """
         logger.info('Purging resource {}'.format(self.uid))
         refint = rdfly.config['referential_integrity']
         inbound = True if refint else inbound
+
+        for desc_uri in rdfly.get_descendants(self.uid):
+            rdfly.forget_rsrc(rdfly.uri_to_uid(desc_uri), inbound)
+
         rdfly.forget_rsrc(self.uid, inbound)
 
-        # @TODO This could be a different event type.
         return RES_DELETED
 
 
-    def create_rsrc_snapshot(self, ver_uid):
+    def resurrect(self):
+        """
+        Resurrect a resource from a tombstone.
         """
-        Perform version creation and return the version UID.
+        remove_trp = {
+            (self.uri, nsc['rdf'].type, nsc['fcsystem'].Tombstone),
+            (self.uri, nsc['fcsystem'].tombstone, None),
+            (self.uri, nsc['fcsystem'].buried, None),
+        }
+        add_trp = {
+            (self.uri, nsc['rdf'].type, nsc['ldp'].Resource),
+        }
+
+        self.modify(RES_CREATED, remove_trp, add_trp)
+
+        # Resurrect descendants.
+        from lakesuperior.model.ldp_factory import LdpFactory
+        descendants = env.app_globals.rdfly.get_descendants(self.uid)
+        for desc_uri in descendants:
+            LdpFactory.from_stored(
+                    rdfly.uri_to_uid(desc_uri), strict=False).resurrect()
+
+        return self.uri
+
+
+    def create_version(self, ver_uid=None):
+        """
+        Create a new version of the resource.
+
+        **Note:** This creates an event only for the resource being updated
+        (due to the added `hasVersion` triple and possibly to the
+        ``hasVersions`` one) but not for the version being created.
+
+        :param str ver_uid: Version UID. If already existing, a new version UID
+            is minted.
         """
+        if not ver_uid or ver_uid in self.version_uids:
+            ver_uid = str(uuid4())
+
         # Create version resource from copying the current state.
         logger.info(
             'Creating version snapshot {} for resource {}.'.format(
@@ -455,19 +532,8 @@ class Ldpr(metaclass=ABCMeta):
         ver_add_gr.add((ver_uri, RDF.type, nsc['fcrepo'].Version))
         for t in self.imr:
             if (
-                t[1] == RDF.type and t[2] in {
-                    nsc['fcrepo'].Binary,
-                    nsc['fcrepo'].Container,
-                    nsc['fcrepo'].Resource,
-                }
-            ) or (
-                t[1] in {
-                    nsc['fcrepo'].hasParent,
-                    nsc['fcrepo'].hasVersions,
-                    nsc['fcrepo'].hasVersion,
-                    nsc['premis'].hasMessageDigest,
-                }
-            ):
+                t[1] == RDF.type and t[2] in self._ignore_version_types
+            ) or t[1] in self._ignore_version_preds:
                 pass
             else:
                 ver_add_gr.add((
@@ -486,59 +552,6 @@ class Ldpr(metaclass=ABCMeta):
         return ver_uid
 
 
-    def resurrect_rsrc(self):
-        """
-        Resurrect a resource from a tombstone.
-
-        @EXPERIMENTAL
-        """
-        tstone_trp = set(rdfly.get_imr(self.uid, strict=False))
-
-        ver_rsp = self.version_info.query('''
-        SELECT ?uid {
-          ?latest fcrepo:hasVersionLabel ?uid ;
-            fcrepo:created ?ts .
-        }
-        ORDER BY DESC(?ts)
-        LIMIT 1
-        ''')
-        ver_uid = str(ver_rsp.bindings[0]['uid'])
-        ver_trp = set(rdfly.get_metadata(self.uid, ver_uid))
-
-        laz_gr = Graph()
-        for t in ver_trp:
-            if t[1] != RDF.type or t[2] not in {
-                nsc['fcrepo'].Version,
-            }:
-                laz_gr.add((self.uri, t[1], t[2]))
-        laz_gr.add((self.uri, RDF.type, nsc['fcrepo'].Resource))
-        if nsc['ldp'].NonRdfSource in laz_gr[:RDF.type:]:
-            laz_gr.add((self.uri, RDF.type, nsc['fcrepo'].Binary))
-        elif nsc['ldp'].Container in laz_gr[:RDF.type:]:
-            laz_gr.add((self.uri, RDF.type, nsc['fcrepo'].Container))
-
-        laz_set = set(laz_gr) | self._containment_rel()
-        self.modify(RES_CREATED, tstone_trp, laz_set)
-
-        return self.uri
-
-
-
-    def create_version(self, ver_uid=None):
-        """
-        Create a new version of the resource.
-
-        **Note:** This creates an event only for the resource being updated
-        (due to the added `hasVersion` triple and possibly to the
-        ``hasVersions`` one) but not for the version being created.
-
-        :param str ver_uid: Version UID. If already existing, a new version UID
-            is minted.
-        """
-        if not ver_uid or ver_uid in self.version_uids:
-            ver_uid = str(uuid4())
-
-        return self.create_rsrc_snapshot(ver_uid)
 
 
     def revert_to_version(self, ver_uid, backup=True):
@@ -616,7 +629,7 @@ class Ldpr(metaclass=ABCMeta):
         return trp
 
 
-    def sparql_delta(self, q):
+    def sparql_delta(self, qry_str):
         """
         Calculate the delta obtained by a SPARQL Update operation.
 
@@ -640,11 +653,18 @@ class Ldpr(metaclass=ABCMeta):
             with ``BaseStoreLayout.update_resource`` and/or recorded as separate
             events in a provenance tracking system.
         """
-        logger.debug('Provided SPARQL query: {}'.format(q))
-        pre_gr = self.imr
+        logger.debug('Provided SPARQL query: {}'.format(qry_str))
+        # Workaround for RDFLib bug. See
+        # https://github.com/RDFLib/rdflib/issues/824
+        qry_str = (
+                re.sub('<#([^>]+)>', '<{}#\\1>'.format(self.uri), qry_str)
+                .replace('<>', '<{}>'.format(self.uri)))
+        pre_gr = Graph(identifier=self.uri)
+        pre_gr += self.imr
+        post_gr = Graph(identifier=self.uri)
+        post_gr += self.imr
 
-        post_gr = pre_gr | Graph()
-        post_gr.update(q)
+        post_gr.update(qry_str)
 
         remove_gr, add_gr = self._dedup_deltas(pre_gr, post_gr)
 
@@ -689,6 +709,13 @@ class Ldpr(metaclass=ABCMeta):
         :param set add_trp: Triples to be added.
         """
         rdfly.modify_rsrc(self.uid, remove_trp, add_trp)
+        # Clear IMR buffer.
+        if hasattr(self, '_imr'):
+            delattr(self, '_imr')
+            try:
+                self.imr
+            except (ResourceNotExistsError, TombstoneError):
+                pass
 
         if (
                 ev_type is not None and

+ 73 - 49
lakesuperior/store/ldp_rs/rsrc_centric_layout.py

@@ -77,6 +77,7 @@ class RsrcCentricLayout:
                 nsc['ldp'].membershipResource,
                 nsc['ldp'].hasMemberRelation,
                 nsc['ldp'].insertedContentRelation,
+
                 nsc['iana'].describedBy,
                 nsc['premis'].hasMessageDigest,
                 nsc['premis'].hasSize,
@@ -88,6 +89,7 @@ class RsrcCentricLayout:
                 nsc['fcrepo'].Container,
                 nsc['fcrepo'].Pairtree,
                 nsc['fcrepo'].Resource,
+                nsc['fcrepo'].Version,
                 nsc['fcsystem'].Tombstone,
                 nsc['ldp'].BasicContainer,
                 nsc['ldp'].Container,
@@ -106,13 +108,35 @@ class RsrcCentricLayout:
             }
         },
     }
+    """
+    Human-manageable map of attribute routes.
+
+    This serves as the source for :data:`attr_routes`.
+    """
 
-    # RDF types of graphs by prefix.
     graph_ns_types = {
         nsc['fcadmin']: nsc['fcsystem'].AdminGraph,
         nsc['fcmain']: nsc['fcsystem'].UserProvidedGraph,
         nsc['fcstruct']: nsc['fcsystem'].StructureGraph,
     }
+    """
+    RDF types of graphs by prefix.
+    """
+
+    ignore_vmeta_preds = {
+        nsc['foaf'].primaryTopic,
+    }
+    """
+    Predicates of version metadata to be ignored in output.
+    """
+
+    ignore_vmeta_types = {
+        nsc['fcsystem'].AdminGraph,
+        nsc['fcsystem'].UserProvidedGraph,
+    }
+    """
+    RDF types of version metadata to be ignored in output.
+    """
 
 
     ## MAGIC METHODS ##
@@ -293,6 +317,7 @@ class RsrcCentricLayout:
         Get all the user-provided data.
 
         :param string uid: Resource UID.
+        :rtype: rdflib.Graph
         """
         # *TODO* This only works as long as there is only one user-provided
         # graph. If multiple user-provided graphs will be supported, this
@@ -303,43 +328,42 @@ class RsrcCentricLayout:
         return userdata_gr
 
 
-    def get_version_info(self, uid, strict=True):
+    def get_version_info(self, uid):
         """
         Get all metadata about a resource's versions.
+
+        :param string uid: Resource UID.
+        :rtype: rdflib.Graph
         """
-        # **Note:** This pretty much bends the ontology—it replaces the graph URI
-        # with the subject URI. But the concepts of data and metadata in Fedora
-        # are quite fluid anyways...
-
-        # WIP—Is it worth to replace SPARQL here?
-        #versions = self.ds.graph(nsc['fcadmin'][uid]).triples(
-        #        (nsc['fcres'][uid], nsc['fcrepo'].hasVersion, None))
-        #for version in versions:
-        #    version_meta = self.ds.graph(HIST_GRAPH_URI).triples(
-        qry = """
-        CONSTRUCT {
-          ?s fcrepo:hasVersion ?v .
-          ?v ?p ?o .
-        } {
-          GRAPH ?ag {
-            ?s fcrepo:hasVersion ?v .
-          }
-          GRAPH ?hg {
-            ?vm foaf:primaryTopic ?v .
-            ?vm  ?p ?o .
-            FILTER (?o != ?v)
-          }
-        }"""
-        gr = self._parse_construct(qry, init_bindings={
-            'ag': nsc['fcadmin'][uid],
-            'hg': HIST_GR_URI,
-            's': nsc['fcres'][uid]})
-        ver_info_gr = Graph(identifier=nsc['fcres'][uid])
-        ver_info_gr += gr
-        if strict:
-            self._check_rsrc_status(ver_info_gr)
+        # **Note:** This pretty much bends the ontology—it replaces the graph
+        # URI with the subject URI. But the concepts of data and metadata in
+        # Fedora are quite fluid anyways...
+
+        # Result graph.
+        vmeta_gr = Graph(identifier=nsc['fcres'][uid])
 
-        return ver_info_gr
+        # Get version meta graphs.
+        v_triples = self.ds.graph(nsc['fcadmin'][uid]).triples(
+                (nsc['fcres'][uid], nsc['fcrepo'].hasVersion, None))
+
+        #import pdb; pdb.set_trace()
+        #Get version graphs proper.
+        for vtrp in v_triples:
+            # While at it, add the hasVersion triple to the result graph.
+            vmeta_gr.add(vtrp)
+            vmeta_uris = self.ds.graph(HIST_GR_URI).subjects(
+                    nsc['foaf'].primaryTopic, vtrp[2])
+            # Get triples in the meta graph filtering out undesired triples.
+            for vmuri in vmeta_uris:
+                for trp in self.ds.graph(HIST_GR_URI).triples(
+                        (vmuri, None, None)):
+                    if (
+                            (trp[1] != nsc['rdf'].type
+                            or trp[2] not in self.ignore_vmeta_types)
+                            and (trp[1] not in self.ignore_vmeta_preds)):
+                        vmeta_gr.add((vtrp[2], trp[1], trp[2]))
+
+        return vmeta_gr
 
 
     def get_inbound_rel(self, subj_uri, full_triple=True):
@@ -394,6 +418,21 @@ class RsrcCentricLayout:
             else ds.graph(ctx_uri)[subj_uri : nsc['ldp'].contains : ])
 
 
+    def get_last_version_uid(self, uid):
+        """
+        Get the UID of the last version of a resource.
+
+        This can be used for tombstones too.
+        """
+        ver_info = self.get_version_info(uid)
+        last_version_uri = sorted(
+            [trp for trp in ver_info if trp[1] == nsc['fcrepo'].created],
+            key=lambda trp:trp[2]
+        )[-1][0]
+
+        return str(last_version_uri).split(VERS_CONT_LABEL + '/')[-1]
+
+
     def patch_rsrc(self, uid, qry):
         """
         Patch a resource with SPARQL-Update statements.
@@ -594,21 +633,6 @@ class RsrcCentricLayout:
                 gr.value(gr.identifier, nsc['fcrepo'].created))
 
 
-    def _parse_construct(self, qry, init_bindings={}):
-        """
-        Parse a CONSTRUCT query.
-
-        :rtype: rdflib.Graph
-        """
-        try:
-            qres = self.ds.query(qry, initBindings=init_bindings)
-        except ResultException:
-            # RDFlib bug: https://github.com/RDFLib/rdflib/issues/775
-            return Graph()
-        else:
-            return qres.graph
-
-
     def _map_graph_uri(self, t, uid):
         """
         Map a triple to a namespace prefix corresponding to a graph.

+ 222 - 1
tests/api/test_resource_api.py

@@ -46,7 +46,7 @@ def ic_rdf():
 
 
 @pytest.mark.usefixtures('db')
-class TestResourceApi:
+class TestResourceCRUD:
     '''
     Test interaction with the Resource API.
     '''
@@ -250,6 +250,42 @@ class TestResourceApi:
             rsrc.uri : nsc['foaf'].name : Literal('Joe 12oz Bob')]
 
 
+    def test_sparql_update(self):
+        """
+        Update a resource using a SPARQL Update string.
+
+        Use a mix of relative and absolute URIs.
+        """
+        uid = '/test_sparql'
+        rdf_data = b'<> <http://purl.org/dc/terms/title> "Original title." .'
+        update_str = '''DELETE {
+        <> <http://purl.org/dc/terms/title> "Original title." .
+        } INSERT {
+        <> <http://purl.org/dc/terms/title> "Title #2." .
+        <info:fcres/test_sparql>
+          <http://purl.org/dc/terms/title> "Title #3." .
+        <#h1> <http://purl.org/dc/terms/title> "This is a hash." .
+        } WHERE {
+        }'''
+        rsrc_api.create_or_replace(uid, rdf_data=rdf_data, rdf_fmt='turtle')
+        ver_uid = rsrc_api.create_version(uid, 'v1').split('fcr:versions/')[-1]
+
+        rsrc = rsrc_api.update(uid, update_str)
+        assert (
+            (rsrc.uri, nsc['dcterms'].title, Literal('Original title.'))
+            not in set(rsrc.imr))
+        assert (
+            (rsrc.uri, nsc['dcterms'].title, Literal('Title #2.'))
+            in set(rsrc.imr))
+        assert (
+            (rsrc.uri, nsc['dcterms'].title, Literal('Title #3.'))
+            in set(rsrc.imr))
+        assert ((
+                URIRef(str(rsrc.uri) + '#h1'),
+                nsc['dcterms'].title, Literal('This is a hash.'))
+            in set(rsrc.imr))
+
+
     def test_create_ldp_dc_post(self, dc_rdf):
         """
         Create an LDP Direct Container via POST.
@@ -325,3 +361,188 @@ class TestResourceApi:
             top_cont_rsrc.uri: nsc['dcterms'].relation:
             nsc['fcres'][target_uid]]
 
+
+    def test_soft_delete(self):
+        """
+        Soft-delete (bury) a resource.
+        """
+        uid = '/test_soft_delete01'
+        rsrc_api.create_or_replace(uid)
+        rsrc_api.delete(uid)
+        with pytest.raises(TombstoneError):
+            rsrc_api.get(uid)
+
+
+    def test_resurrect(self):
+        """
+        Restore (resurrect) a soft-deleted resource.
+        """
+        uid = '/test_soft_delete02'
+        rsrc_api.create_or_replace(uid)
+        rsrc_api.delete(uid)
+        rsrc_api.resurrect(uid)
+
+        rsrc = rsrc_api.get(uid)
+        assert nsc['ldp'].Resource in rsrc.ldp_types
+
+
+    def test_hard_delete(self):
+        """
+        Hard-delete (forget) a resource.
+        """
+        uid = '/test_hard_delete01'
+        rsrc_api.create_or_replace(uid)
+        rsrc_api.delete(uid, False)
+        with pytest.raises(ResourceNotExistsError):
+            rsrc_api.get(uid)
+        with pytest.raises(ResourceNotExistsError):
+            rsrc_api.resurrect(uid)
+
+
+    def test_delete_children(self):
+        """
+        Soft-delete a resource with children.
+        """
+        uid = '/test_soft_delete_children01'
+        rsrc_api.create_or_replace(uid)
+        for i in range(3):
+            rsrc_api.create_or_replace('{}/child{}'.format(uid, i))
+        rsrc_api.delete(uid)
+        with pytest.raises(TombstoneError):
+            rsrc_api.get(uid)
+        for i in range(3):
+            with pytest.raises(TombstoneError):
+                rsrc_api.get('{}/child{}'.format(uid, i))
+            # Cannot resurrect children of a tombstone.
+            with pytest.raises(TombstoneError):
+                rsrc_api.resurrect('{}/child{}'.format(uid, i))
+
+
+    def test_resurrect_children(self):
+        """
+        Resurrect a resource with its children.
+
+        This uses fixtures from the previous test.
+        """
+        uid = '/test_soft_delete_children01'
+        rsrc_api.resurrect(uid)
+        parent_rsrc = rsrc_api.get(uid)
+        assert nsc['ldp'].Resource in parent_rsrc.ldp_types
+        for i in range(3):
+            child_rsrc = rsrc_api.get('{}/child{}'.format(uid, i))
+            assert nsc['ldp'].Resource in child_rsrc.ldp_types
+
+
+    def test_hard_delete_children(self):
+        """
+        Hard-delete (forget) a resource with its children.
+
+        This uses fixtures from the previous test.
+        """
+        uid = '/test_hard_delete_children01'
+        rsrc_api.create_or_replace(uid)
+        for i in range(3):
+            rsrc_api.create_or_replace('{}/child{}'.format(uid, i))
+        rsrc_api.delete(uid, False)
+        with pytest.raises(ResourceNotExistsError):
+            rsrc_api.get(uid)
+        with pytest.raises(ResourceNotExistsError):
+            rsrc_api.resurrect(uid)
+
+        for i in range(3):
+            with pytest.raises(ResourceNotExistsError):
+                rsrc_api.get('{}/child{}'.format(uid, i))
+            with pytest.raises(ResourceNotExistsError):
+                rsrc_api.resurrect('{}/child{}'.format(uid, i))
+
+
+
+@pytest.mark.usefixtures('db')
+class TestResourceVersioning:
+    '''
+    Test resource version lifecycle.
+    '''
+    def test_create_version(self):
+        """
+        Create a version snapshot.
+        """
+        uid = '/test_version1'
+        rdf_data = b'<> <http://purl.org/dc/terms/title> "Original title." .'
+        update_str = '''DELETE {
+        <> <http://purl.org/dc/terms/title> "Original title." .
+        } INSERT {
+        <> <http://purl.org/dc/terms/title> "Title #2." .
+        } WHERE {
+        }'''
+        rsrc_api.create_or_replace(uid, rdf_data=rdf_data, rdf_fmt='turtle')
+        ver_uid = rsrc_api.create_version(uid, 'v1').split('fcr:versions/')[-1]
+
+        rsrc_api.update(uid, update_str)
+        current = rsrc_api.get(uid)
+        assert (
+            (current.uri, nsc['dcterms'].title, Literal('Title #2.'))
+            in current.imr)
+        assert (
+            (current.uri, nsc['dcterms'].title, Literal('Original title.'))
+            not in current.imr)
+
+        v1 = rsrc_api.get_version(uid, ver_uid)
+        assert (
+            (v1.identifier, nsc['dcterms'].title, Literal('Original title.'))
+            in set(v1))
+        assert (
+            (v1.identifier, nsc['dcterms'].title, Literal('Title #2.'))
+            not in set(v1))
+
+
+    def test_revert_to_version(self):
+        """
+        Test reverting to a previous version.
+
+        Uses assets from previous test.
+        """
+        uid = '/test_version1'
+        ver_uid = 'v1'
+        rsrc_api.revert_to_version(uid, ver_uid)
+        rev = rsrc_api.get(uid)
+        assert (
+            (rev.uri, nsc['dcterms'].title, Literal('Original title.'))
+            in rev.imr)
+
+
+    def test_versioning_children(self):
+        """
+        Test that children are not affected by version restoring.
+
+        This test does the following:
+
+        1. create parent resource
+        2. Create child 1
+        3. Version parent
+        4. Create child 2
+        5. Restore parent to previous version
+        6. Verify that restored version still has 2 children
+        """
+        uid = '/test_version_children'
+        ver_uid = 'v1'
+        ch1_uid = '{}/kid_a'.format(uid)
+        ch2_uid = '{}/kid_b'.format(uid)
+        rsrc_api.create_or_replace(uid)
+        rsrc_api.create_or_replace(ch1_uid)
+        ver_uid = rsrc_api.create_version(uid, ver_uid).split('fcr:versions/')[-1]
+        rsrc = rsrc_api.get(uid)
+        assert nsc['fcres'][ch1_uid] in rsrc.imr.objects(
+                rsrc.uri, nsc['ldp'].contains)
+
+        rsrc_api.create_or_replace(ch2_uid)
+        rsrc = rsrc_api.get(uid)
+        assert nsc['fcres'][ch2_uid] in rsrc.imr.objects(
+                rsrc.uri, nsc['ldp'].contains)
+
+        rsrc_api.revert_to_version(uid, ver_uid)
+        rsrc = rsrc_api.get(uid)
+        assert nsc['fcres'][ch1_uid] in rsrc.imr.objects(
+                rsrc.uri, nsc['ldp'].contains)
+        assert nsc['fcres'][ch2_uid] in rsrc.imr.objects(
+                rsrc.uri, nsc['ldp'].contains)
+

+ 19 - 0
tests/endpoints/test_ldp.py

@@ -263,6 +263,25 @@ class TestLdp:
         assert 'Location' in res.headers
 
 
+    def test_post_ldp_nr(self, rnd_img):
+        '''
+        POST a resource with binary payload and verify checksums.
+        '''
+        rnd_img['content'].seek(0)
+        resp = self.client.post('/ldp/', data=rnd_img['content'],
+                headers={
+                    'slug': 'ldpnr03',
+                    'Content-Type': 'image/png',
+                    'Content-Disposition' : 'attachment; filename={}'.format(
+                    rnd_img['filename'])})
+        assert resp.status_code == 201
+
+        resp = self.client.get(
+                '/ldp/ldpnr03', headers={'accept' : 'image/png'})
+        assert resp.status_code == 200
+        assert sha1(resp.data).hexdigest() == rnd_img['hash']
+
+
     def test_post_slug(self):
         '''
         Verify that a POST with slug results in the expected URI only if the