# HG changeset patch # User Drew Perttula # Date 2016-06-06 05:57:30 # Node ID 233b81d9bd9d16843696d58a6e306099ac74d497 # Parent 168262618f2d88c4b32e2e5528ba4c060d2f8372 simple first version of SyncedGraph.runHandler Ignore-this: bbe6aa9fe4edd0de1564b57c1077e711 diff --git a/.boring b/.boring --- a/.boring +++ b/.boring @@ -147,6 +147,7 @@ ^show/dance2014/music ^stagesim/three.js-master ^tkdnd +^node_modules # temporary! rgbled/build-nano328/ diff --git a/light9/web/graph.coffee b/light9/web/graph.coffee --- a/light9/web/graph.coffee +++ b/light9/web/graph.coffee @@ -7,15 +7,20 @@ log = console.log 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 @@ class GraphWatchers 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 @@ class window.SyncedGraph 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 @@ class window.SyncedGraph 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 @@ class window.SyncedGraph @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 @@ class window.SyncedGraph 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 @@ class window.SyncedGraph @_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 + diff --git a/light9/web/graph_test.coffee b/light9/web/graph_test.coffee --- a/light9/web/graph_test.coffee +++ b/light9/web/graph_test.coffee @@ -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 @@ describe 'SyncedGraph', -> 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 : . + :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 : . + :ctx { :x :y (:a1 :a2 :a3) } . + ", () -> + graph.runHandler(hand) + graph.clearGraph() + graph.loadTrig " + @prefix : . + :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() diff --git a/makefile b/makefile --- a/makefile +++ b/makefile @@ -17,6 +17,10 @@ test_js: 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: