view rdfdb/patch.py @ 1:674833ada390

no light9 package; rdfdb is now a toplevel name Ignore-this: 2b7d8c53df9e62c9d3fbb38b083b122f
author Drew Perttula <drewp@bigasterisk.com>
date Sun, 18 Feb 2018 09:17:36 +0000
parents d487d597ad33
children 33375313f7ed
line wrap: on
line source

import json, unittest
from rdflib import ConjunctiveGraph, Graph, URIRef, URIRef as U, Literal
from light9.namespaces import XSD
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=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
        """
        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 = body['senderUpdateUri']

    def __str__(self):
        def shorten(n):
            if isinstance(n, Literal):
                if n.datatype == XSD['double']:
                    return str(n.toPython())
            if isinstance(n, URIRef):
                for long, short in [
                        ("http://light9.bigasterisk.com/", "l9"),
                        
                ]:
                    if n.startswith(long):
                        return short+":"+n[len(long):]
            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 __nonzero__(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):
        if self._jsonRepr is None:
            self._jsonRepr = self.makeJsonRepr()
        return self._jsonRepr

    def makeJsonRepr(self, extraAttrs={}):
        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)
        return json.dumps(d)

    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.assert_(not p)

    def testNonEmpty(self):
        g1 = ConjunctiveGraph()
        g2 = graphFromQuads([stmt1])
        p = Patch.fromDiff(g1, g2)
        self.assert_(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)