view rdfdb/patch.py @ 71:8a60c172a14c

use standardservice to serve cpu/ram stats Ignore-this: 425713e20aa4fb79b82dfd77b08ef4b0
author drewp@bigasterisk.com
date Fri, 07 Jun 2019 00:03:20 +0000
parents 54664942cc61
children 22c9679dbf67
line wrap: on
line source

import json, unittest
from rdflib import ConjunctiveGraph, Graph, URIRef, URIRef as U, Literal, Namespace
from typing import Optional
XSD = Namespace("http://www.w3.org/2001/XMLSchema#")

from rdfdb.rdflibpatch import graphFromNQuad, graphFromQuads, serializeQuad

ALLSTMTS = (None, None, None)


def quadsWithContextUris(quads):
    """
    yield the given quads, correcting any context values that are
    Graphs into URIRefs
    """
    if isinstance(quads, ConjunctiveGraph):
        quads = quads.quads(ALLSTMTS)
    for s, p, o, c in quads:
        if isinstance(c, Graph):
            c = c.identifier
        if not isinstance(c, URIRef):
            raise TypeError("bad quad context type in %r" % ((s, p, o, c),))
        yield s, p, o, c


class Patch(object):
    """
    immutable
    
    the json representation includes the {"patch":...} wrapper
    """

    def __init__(self,
                 jsonRepr: Optional[str] = None,
                 addQuads=None,
                 delQuads=None,
                 addGraph=None,
                 delGraph=None):
        """
        addQuads/delQuads can be lists or sets, but if we make them internally,
        they'll be lists

        4th element of a quad must be a URIRef
        """
        assert jsonRepr is None or isinstance(jsonRepr, str), repr(jsonRepr)
        self._jsonRepr = jsonRepr
        self._addQuads, self._delQuads = addQuads, delQuads
        self._addGraph, self._delGraph = addGraph, delGraph

        if self._jsonRepr is not None:
            body = json.loads(self._jsonRepr)
            self._delGraph = graphFromNQuad(body['patch']['deletes'])
            self._addGraph = graphFromNQuad(body['patch']['adds'])
            if 'senderUpdateUri' in body:
                self.senderUpdateUri = URIRef(body['senderUpdateUri'])

    def __str__(self):

        def shorten(n):
            if isinstance(n, Literal):
                if n.datatype == XSD['double']:
                    return str(n.toPython())
            return n.n3()

        def formatQuad(quad):
            return " ".join(shorten(n) for n in quad)

        delLines = ["  -%s" % formatQuad(q) for q in self.delQuads]
        addLines = ["  +%s" % formatQuad(q) for q in self.addQuads]
        return "\nPatch:\n" + "\n".join(delLines) + "\n" + "\n".join(addLines)

    def shortSummary(self):
        return "[-%s +%s]" % (len(self.delQuads), len(self.addQuads))

    @classmethod
    def fromDiff(cls, oldGraph, newGraph):
        """
        make a patch that changes oldGraph to newGraph
        """
        old = set(quadsWithContextUris(oldGraph))
        new = set(quadsWithContextUris(newGraph))
        return cls(addQuads=list(new - old), delQuads=list(old - new))

    def __bool__(self):
        """
        does this patch do anything to a graph?
        """
        if self._jsonRepr and self._jsonRepr.strip():
            raise NotImplementedError()
        return bool(self._addQuads or self._delQuads or self._addGraph or
                    self._delGraph)

    @property
    def addQuads(self):
        if self._addQuads is None:
            if self._addGraph is None:
                return []
            self._addQuads = list(
                quadsWithContextUris(self._addGraph.quads(ALLSTMTS)))
        return self._addQuads

    @property
    def delQuads(self):
        if self._delQuads is None:
            if self._delGraph is None:
                return []
            self._delQuads = list(
                quadsWithContextUris(self._delGraph.quads(ALLSTMTS)))
        return self._delQuads

    @property
    def addGraph(self):
        if self._addGraph is None:
            self._addGraph = graphFromQuads(self.addQuads)
        return self._addGraph

    @property
    def delGraph(self):
        if self._delGraph is None:
            self._delGraph = graphFromQuads(self.delQuads)
        return self._delGraph

    @property
    def jsonRepr(self) -> str:
        if self._jsonRepr is None:
            self._jsonRepr = self.makeJsonRepr()
        return self._jsonRepr

    def makeJsonRepr(self, extraAttrs={}) -> str:
        d = {
            "patch": {
                'adds': serializeQuad(self.addGraph),
                'deletes': serializeQuad(self.delGraph),
            }
        }
        if len(self.addGraph) > 0 and d['patch']['adds'].strip() == "":
            # this is the bug that graphFromNQuad works around
            raise ValueError("nquads serialization failure")
        if '[<' in d['patch']['adds']:
            raise ValueError("[< found in %s" % d['patch']['adds'])
        d.update(extraAttrs)
        s = json.dumps(d)
        assert isinstance(s, str), repr(s)
        return s

    def simplify(self):
        adds = set(self.addQuads)
        dels = set(self.delQuads)
        both = adds.intersection(dels)
        if not both:
            return self
        return Patch(addQuads=adds - both, delQuads=dels - both)

    def concat(self, more):
        """
        new Patch with the result of applying this patch and the
        sequence of other Patches
        """
        # not working yet
        adds = set(self.addQuads)
        dels = set(self.delQuads)
        for p2 in more:
            for q in p2.delQuads:
                if q in adds:
                    adds.remove(q)
                else:
                    dels.add(q)
            for q in p2.addQuads:
                if q in dels:
                    dels.remove(q)
                else:
                    adds.add(q)
        return Patch(delQuads=dels, addQuads=adds)

    def getContext(self):
        """assumes that all the edits are on the same context"""
        ctx = None
        for q in self.addQuads + self.delQuads:
            if ctx is None:
                ctx = q[3]

            if ctx != q[3]:
                raise ValueError(
                    "patch applies to multiple contexts, at least %r and %r" %
                    (ctx, q[3]))
        if ctx is None:
            raise ValueError("patch affects no contexts")
        assert isinstance(ctx, URIRef), ctx
        return ctx

    def isNoop(self):
        return set(self.addQuads) == set(self.delQuads)


stmt1 = U('http://a'), U('http://b'), U('http://c'), U('http://ctx1')


class TestPatchFromDiff(unittest.TestCase):

    def testEmpty(self):
        g = ConjunctiveGraph()
        p = Patch.fromDiff(g, g)
        self.assertTrue(not p)

    def testNonEmpty(self):
        g1 = ConjunctiveGraph()
        g2 = graphFromQuads([stmt1])
        p = Patch.fromDiff(g1, g2)
        self.assertTrue(p)

    def testNoticesAdds(self):
        g1 = ConjunctiveGraph()
        g2 = graphFromQuads([stmt1])
        p = Patch.fromDiff(g1, g2)
        self.assertEqual(p.addQuads, [stmt1])
        self.assertEqual(p.delQuads, [])

    def testNoticesDels(self):
        g1 = graphFromQuads([stmt1])
        g2 = ConjunctiveGraph()
        p = Patch.fromDiff(g1, g2)
        self.assertEqual(p.addQuads, [])
        self.assertEqual(p.delQuads, [stmt1])

    def testQuadSequenceOkInsteadOfGraph(self):
        p = Patch.fromDiff([stmt1], ConjunctiveGraph())
        self.assertEqual(p.delQuads, [stmt1])
        p = Patch.fromDiff(ConjunctiveGraph(), [stmt1])
        self.assertEqual(p.addQuads, [stmt1])


class TestPatchGetContext(unittest.TestCase):

    def testEmptyPatchCantGiveContext(self):
        p = Patch()
        self.assertRaises(ValueError, p.getContext)

    def testSimplePatchReturnsContext(self):
        p = Patch(addQuads=[stmt1])
        self.assertEqual(p.getContext(), U('http://ctx1'))

    def testMultiContextPatchFailsToReturnContext(self):
        p = Patch(addQuads=[
            stmt1[:3] + (U('http://ctx1'),), stmt1[:3] + (U('http://ctx2'),)
        ])
        self.assertRaises(ValueError, p.getContext)