changeset 1363:233b81d9bd9d

simple first version of SyncedGraph.runHandler Ignore-this: bbe6aa9fe4edd0de1564b57c1077e711
author Drew Perttula <drewp@bigasterisk.com>
date Mon, 06 Jun 2016 05:57:30 +0000
parents 168262618f2d
children b7023c28dd4f
files .boring light9/web/graph.coffee light9/web/graph_test.coffee makefile
diffstat 4 files changed, 338 insertions(+), 9 deletions(-) [+]
line wrap: on
line diff
--- a/.boring	Sun Jun 05 22:41:04 2016 +0000
+++ b/.boring	Mon Jun 06 05:57:30 2016 +0000
@@ -147,6 +147,7 @@
 ^show/dance2014/music
 ^stagesim/three.js-master
 ^tkdnd
+^node_modules
 
 # temporary!
 rgbled/build-nano328/
--- a/light9/web/graph.coffee	Sun Jun 05 22:41:04 2016 +0000
+++ b/light9/web/graph.coffee	Mon Jun 06 05:57:30 2016 +0000
@@ -7,15 +7,20 @@
 if require?
   `window = {}`
   `N3 = require('./lib/N3.js-1d2d975c10ad3252d38393c3ea97b36fd3ab986a/N3.js')`
+  `d3 = require('./lib/d3/build/d3.min.js')`
   `RdfDbClient = require('./rdfdbclient.js').RdfDbClient`
   module.exports = window
 
-# (sloppily shared to rdfdbclient.coffee too)
-window.patchSizeSummary = (patch) ->
+RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+
+patchSizeSummary = (patch) ->
   '-' + patch.delQuads.length + ' +' + patch.addQuads.length
 
+# (sloppily shared to rdfdbclient.coffee too)
+window.patchSizeSummary = patchSizeSummary
+
 # partial port of autodepgraphapi.py
-class GraphWatchers
+class GraphWatchers # throw this one away; use AutoDependencies
   constructor: ->
     @handlersSp = {} # {s: {p: [handlers]}}
   subscribe: (s, p, o, onChange) -> # return subscription handle
@@ -52,6 +57,49 @@
       for cb in @matchingHandlers(quad)
         cb({delQuads: [], addQuads: [quad]})
 
+class Handler
+  # a function and the quad patterns it cared about
+  constructor: (@func) ->
+    patterns = [] # s,p,o,g quads that should trigger the next run
+  
+class AutoDependencies
+  constructor: () ->
+    @handlers = [] # all known Handlers (at least those with non-empty patterns)
+    @handlerStack = [] # currently running
+    
+  runHandler: (func) ->
+    # what if we have this func already? duplicate is safe?
+    
+    h = new Handler(func)
+    @handlers.push(h)
+    @_rerunHandler(h)
+    
+  _rerunHandler: (handler) ->
+    handler.patterns = []
+    @handlerStack.push(handler)
+    try
+      handler.func()
+    catch e
+      log('error running handler: ', e)
+      # assuming here it didn't get to do all its queries, we could
+      # add a *,*,*,* handler to call for sure the next time?
+    finally
+      #log('done. got: ', handler.patterns)
+      @handlerStack.pop()
+    # handler might have no watches, in which case we could forget about it
+    
+  graphChanged: (patch) ->
+    # SyncedGraph is telling us this patch just got applied to the graph.
+    for h in @handlers
+      @_rerunHandler(h)
+
+  askedFor: (s, p, o, g) ->
+    # SyncedGraph is telling us someone did a query that depended on
+    # quads in the given pattern.
+    current = @handlerStack[@handlerStack.length - 1]
+    if current?
+      current.patterns.push([s, p, o, g])
+      #log('push', s,p,o,g)
 
 class window.SyncedGraph
   # Main graph object for a browser to use. Syncs both ways with
@@ -64,15 +112,16 @@
   constructor: (@patchSenderUrl, @prefixes, @setStatus) ->
     # patchSenderUrl is the /syncedGraph path of an rdfdb server.
     # prefixes can be used in Uri(curie) calls.
-    @_watchers = new GraphWatchers()
+    @_watchers = new GraphWatchers() # old
+    @_autoDeps = new AutoDependencies() # replaces GraphWatchers
     @clearGraph()
 
     if @patchSenderUrl
       @_client = new RdfDbClient(@patchSenderUrl, @clearGraph.bind(@),
                                  @_applyPatch.bind(@), @setStatus)
     
-  clearGraph: ->
-    log('SyncedGraph clear')
+  clearGraph: -> # for debugging
+    # just deletes the statements; watchers are unaffected.
     if @graph?
       @_applyPatch({addQuads: [], delQuads: @graph.find()})
 
@@ -112,9 +161,9 @@
   quads: () -> # for debugging
     [q.subject, q.predicate, q.object, q.graph] for q in @graph.find()
 
-  applyAndSendPatch: (patch, cb) ->
+  applyAndSendPatch: (patch) ->
     @_applyPatch(patch)
-    @_client.sendPatch(patch)
+    @_client.sendPatch(patch) if @_client
 
   _applyPatch: (patch) ->
     # In most cases you want applyAndSendPatch.
@@ -124,8 +173,9 @@
       @graph.removeTriple(quad)
     for quad in patch.addQuads
       @graph.addTriple(quad)
-    log('applied patch locally', patchSizeSummary(patch))
+    #log('applied patch locally', patchSizeSummary(patch))
     @_watchers.graphChanged(patch)
+    @_autoDeps.graphChanged(patch)
 
   getObjectPatch: (s, p, newObject, g) ->
     # send a patch which removes existing values for (s,p,*,c) and
@@ -153,7 +203,14 @@
   unsubscribe: (subscription) ->
     @_watchers.unsubscribe(subscription)
 
+  runHandler: (func) ->
+    # runs your func once, tracking graph calls. if a future patch
+    # matches what you queried, we runHandler your func again (and
+    # forget your queries from the first time).
+    @_autoDeps.runHandler(func)
+
   _singleValue: (s, p) ->
+    @_autoDeps.askedFor(s, p, null, null)
     quads = @graph.findByIRI(s, p)
     switch quads.length
       when 0
@@ -174,12 +231,36 @@
     @_singleValue(s, p)
 
   objects: (s, p) ->
+    @_autoDeps.askedFor(s, p, null, null)
     quads = @graph.findByIRI(s, p)
     return (q.object for q in quads)
 
   subjects: (p, o) ->
+    @_autoDeps.askedFor(null, p, o, null)
+    quads = @graph.findByIRI(null, p, o)
+    return (q.subject for q in quads)
 
   items: (list) ->
+    out = []
+    current = list
+    while true
+      if current == RDF + 'nil'
+        break
+        
+      firsts = @graph.findByIRI(current, RDF + 'first', null)
+      rests = @graph.findByIRI(current, RDF + 'rest', null)
+      if firsts.length != 1
+        throw new Error("list node #{current} has #{firsts.length} rdf:first edges")
+      out.push(firsts[0].object)
+
+      if rests.length != 1
+        throw new Error("list node #{current} has #{rests.length} rdf:rest edges")
+      current = rests[0].object
+    
+    return out
 
   contains: (s, p, o) ->
+    @_autoDeps.askedFor(s, p, o, null)
+    return @graph.findByIRI(s, p, o).length > 0
 
+
--- a/light9/web/graph_test.coffee	Sun Jun 05 22:41:04 2016 +0000
+++ b/light9/web/graph_test.coffee	Mon Jun 06 05:57:30 2016 +0000
@@ -1,4 +1,6 @@
+log = console.log
 assert = require('chai').assert
+expect = require('chai').expect
 SyncedGraph = require('./graph.js').SyncedGraph
 
 describe 'SyncedGraph', ->
@@ -8,3 +10,244 @@
       g.quads()
       assert.equal(g.quads().length, 0)
 
+  describe 'auto dependencies', ->
+    graph = new SyncedGraph()
+    RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+    U = (tail) -> graph.Uri('http://example.com/' + tail)
+    A1 = U('a1')
+    A2 = U('a2')
+    A3 = U('a3')
+    A4 = U('a4')
+    ctx = U('ctx')
+    quad = (s, p, o) -> {subject: s, predicate: p, object: o, graph: ctx}
+
+    beforeEach (done) ->
+      graph = new SyncedGraph()
+      graph.loadTrig("
+        @prefix : <http://example.com/> .
+        :ctx {
+          :a1 :a2 :a3 .
+          :a1 :someFloat 1.5 .
+          :a1 :someString \"hello\" .
+          :a1 :multipleObjects :a4, :a5 .
+          :a2 a :Type1 .
+          :a3 a :Type1 .
+        }
+      ", done)
+    
+    it 'calls a handler right away', ->
+      called = 0
+      hand = ->
+        called++
+      graph.runHandler(hand)
+      assert.equal(1, called)
+      
+    it 'calls a handler a 2nd time if the graph is patched with relevant data', ->
+      called = 0
+      hand = ->
+        called++
+        graph.uriValue(A1, A2)
+      graph.runHandler(hand)
+      graph.applyAndSendPatch({
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
+      assert.equal(2, called) 
+
+    it 'notices new queries a handler makes upon rerun', ->
+      called = 0
+      objsFound = []
+      hand = ->
+        called++
+        graph.uriValue(A1, A2)
+        if called > 1
+          objsFound.push(graph.objects(A1, A3))
+      graph.runHandler(hand)
+      # first run looked up A1,A2,*
+      graph.applyAndSendPatch({
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
+      # second run also looked up A1,A3,* (which matched none)
+      graph.applyAndSendPatch({
+        delQuads: [], addQuads: [quad(A1, A3, A4)]})
+      # third run should happen here, noticing the new A1,A3,* match
+      assert.equal(3, called)
+      assert.deepEqual([[], [A4]], objsFound)
+
+    it 'calls a handler again even if the handler throws an error', ->
+      called = 0
+      hand = ->
+        called++
+        graph.uriValue(A1, A2)
+        throw new Error('this test handler throws an error')
+      graph.runHandler(hand)
+      graph.applyAndSendPatch({
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
+      assert.equal(2, called) 
+
+    describe 'works with nested handlers', ->
+
+      innerResults = []
+      inner = ->
+        innerResults.push(graph.uriValue(A1, A2))
+
+      outerResults = []
+      doRunInner = true
+      outer = ->
+        if doRunInner
+          graph.runHandler(inner)
+        outerResults.push(graph.floatValue(A1, U('someFloat')))
+
+      beforeEach ->
+        innerResults = []
+        outerResults = []
+        doRunInner = true
+
+      affectInner = {
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]
+      }
+      affectOuter = {
+        delQuads: [
+          quad(A1, U('someFloat'), '"1.5"^^http://www.w3.org/2001/XMLSchema#decimal')
+        ], addQuads: [
+          quad(A1, U('someFloat'), graph.LiteralRoundedFloat(2))
+        ]}
+      affectBoth = {
+        delQuads: affectInner.delQuads.concat(affectOuter.delQuads),
+        addQuads: affectInner.addQuads.concat(affectOuter.addQuads)
+        }
+                  
+      it 'calls everything normally once', ->
+        graph.runHandler(outer)
+        assert.deepEqual([A3], innerResults)
+        assert.deepEqual([1.5], outerResults)
+
+      it '[performance] reruns just the inner if its dependencies change', ->
+        graph.runHandler(outer)
+        graph.applyAndSendPatch(affectInner)
+        assert.deepEqual([A3, A4], innerResults)
+        assert.deepEqual([1.5], outerResults)
+        
+      it '[performance] reruns the outer (and therefore inner) if its dependencies change', ->
+        graph.runHandler(outer)
+        graph.applyAndSendPatch(affectOuter)
+        assert.deepEqual([A3, A3], innerResults)
+        assert.deepEqual([1.5, 2], outerResults)
+        
+        
+      it '[performance] does not send a redundant inner run if it is already rerunning outer', ->
+        # Note that outer may or may not call inner each time, and we
+        # don't want to redundantly call inner. We need to:
+        #  1. build the set of handlers to rerun,
+        #  2. call them from outside-in, and
+        #  3. any runHandler calls that happen, they need to count as reruns.
+        graph.runHandler(outer)
+        graph.applyAndSendPatch(affectBoth)
+        assert.deepEqual([A3, A4], innerResults)
+        assert.deepEqual([1.5, 2], outerResults)
+
+      it 'reruns the outer and the inner if all dependencies change, but outer omits calling inner this time', ->
+        graph.runHandler(outer)
+        doRunInner = false
+        graph.applyAndSendPatch(affectBoth)
+        assert.deepEqual([A3, A4], innerResults)
+        assert.deepEqual([1.5, 2], outerResults)
+        
+    describe 'watches calls to:', ->
+      it 'floatValue', ->
+        values = []
+        hand = -> values.push(graph.floatValue(A1, U('someFloat')))
+        graph.runHandler(hand)
+        graph.patchObject(A1, U('someFloat'), graph.LiteralRoundedFloat(2), ctx)
+        assert.deepEqual([1.5, 2.0], values)
+        
+      it 'stringValue', ->
+        values = []
+        hand = -> values.push(graph.stringValue(A1, U('someString')))
+        graph.runHandler(hand)
+        graph.patchObject(A1, U('someString'), graph.Literal('world'), ctx)
+        assert.deepEqual(['hello', 'world'], values)
+
+      it 'uriValue', ->
+        # covered above, but this one tests patchObject on a uri, too
+        values = []
+        hand = -> values.push(graph.uriValue(A1, A2))
+        graph.runHandler(hand)
+        graph.patchObject(A1, A2, A4, ctx)
+        assert.deepEqual([A3, A4], values)
+
+      it 'objects', ->
+        values = []
+        hand = -> values.push(graph.objects(A1, U('multipleObjects')))
+        graph.runHandler(hand)
+        graph.patchObject(A1, U('multipleObjects'), U('newOne'), ctx)
+        expect(values[0]).to.deep.have.members([U('a4'), U('a5')])
+        expect(values[1]).to.deep.have.members([U('newOne')])
+
+      it 'subjects', ->
+        values = []
+        rdfType = graph.Uri(RDF + 'type')
+        hand = -> values.push(graph.subjects(rdfType, U('Type1')))
+        graph.runHandler(hand)
+        graph.applyAndSendPatch(
+          {delQuads: [], addQuads: [quad(A4, rdfType, U('Type1'))]})
+        expect(values[0]).to.deep.have.members([A2, A3])
+        expect(values[1]).to.deep.have.members([A2, A3, A4])
+
+      describe 'items', ->
+        it 'when the list order changes', (done) ->
+          values = []
+          successes = 0
+          hand = ->
+            try 
+              head = graph.uriValue(U('x'), U('y'))
+            catch
+              # graph goes empty between clearGraph and loadTrig
+              return
+            values.push(graph.items(head))
+            successes++
+          graph.clearGraph()
+          graph.loadTrig "
+            @prefix : <http://example.com/> .
+            :ctx { :x :y (:a1 :a2 :a3) } .
+          ", () ->
+            graph.runHandler(hand)
+            graph.clearGraph()
+            graph.loadTrig "
+              @prefix : <http://example.com/> .
+              :ctx { :x :y (:a1 :a3 :a2) } .
+            ", () ->
+              assert.deepEqual([[A1, A2, A3], [A1, A3, A2]], values)
+              assert.equal(2, successes)
+              done()
+  
+      describe 'contains', ->
+        it 'when a new triple is added', ->
+          values = []
+          hand = -> values.push(graph.contains(A1, A1, A1))
+          graph.runHandler(hand)
+          graph.applyAndSendPatch(
+            {delQuads: [], addQuads: [quad(A1, A1, A1)]})
+          assert.deepEqual([false, true], values)
+
+        it 'when a relevant triple is removed', ->
+          values = []
+          hand = -> values.push(graph.contains(A1, A2, A3))
+          graph.runHandler(hand)
+          graph.applyAndSendPatch(
+            {delQuads: [quad(A1, A2, A3)], addQuads: []})
+          assert.deepEqual([true, false], values)
+
+    describe 'performs well', ->
+      it "[performance] doesn't call handler a 2nd time if the graph gets an unrelated patch", ->
+        called = 0
+        hand = ->
+          called++
+          graph.uriValue(A1, A2)
+        graph.runHandler(hand)
+        graph.applyAndSendPatch({
+          delQuads: [], addQuads: [quad(A2, A3, A4)]})
+        assert.equal(1, called)
+
+      it '[performance] calls a handler 2x but then not again if the handler stopped caring about the data', ->
+        assert.fail()
+
+      it "[performance] doesn't get slow if the handler makes tons of repetitive lookups", ->
+        assert.fail()
--- a/makefile	Sun Jun 05 22:41:04 2016 +0000
+++ b/makefile	Mon Jun 06 05:57:30 2016 +0000
@@ -17,6 +17,10 @@
 	coffee -c light9/web/*.coffee
 	node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register --globals window,N3 light9/web/graph_test.coffee
 
+test_js_watch:
+	# have coffee continuously running
+	watch -c node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register --globals window,N3 light9/web/graph_test.coffee --colors
+
 # needed packages: python-gtk2 python-imaging
 
 binexec: