import * as d3 from "d3"; import debug from "debug"; import * as N3 from "n3"; import { Quad, Quad_Object, Quad_Predicate, Quad_Subject } from "n3"; import { sortBy, unique } from "underscore"; import { AutoDependencies, HandlerFunc } from "./AutoDependencies"; import { Patch, patchSizeSummary } from "./patch"; import { RdfDbClient } from "./rdfdbclient"; const log = debug("graph"); const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; export class SyncedGraph { private _autoDeps: AutoDependencies; private _client: RdfDbClient; private graph: N3.Store; cachedFloatValues: any; cachedUriValues: any; prefixFuncs: (x: string) => string = (x) => x; serial: any; _nextNumber: any; // Main graph object for a browser to use. Consider using RdfdbSyncedGraph element to create & own // one of these. Syncs both ways with rdfdb. Meant to hide the choice of RDF lib, so we can change it // later. // // Note that _applyPatch is the only method to write to the graph, so // it can fire subscriptions. constructor( // url is the /syncedGraph path of an rdfdb server. public url: any, // prefixes can be used in Uri(curie) calls. public prefixes: { [short: string]: string }, private setStatus: any, // called if we clear the graph private clearCb: any ) { this.graph = new N3.Store(); this._autoDeps = new AutoDependencies(); this.clearGraph(); this._client = new RdfDbClient(this.url, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus); } clearGraph() { // just deletes the statements; watchers are unaffected. this.cachedFloatValues = new Map(); // s + '|' + p -> number this.cachedUriValues = new Map(); // s + '|' + p -> Uri this._applyPatch({ adds: [], dels: this.graph.getQuads(null, null, null, null) }); // if we had a Store already, this lets N3.Store free all its indices/etc this.graph = new N3.Store(); this._addPrefixes(this.prefixes); } _clearGraphOnNewConnection() { // must not send a patch to the server! log("clearGraphOnNewConnection"); this.clearGraph(); log("clearGraphOnNewConnection done"); if (this.clearCb != null) { return this.clearCb(); } } _addPrefixes(prefixes: { [x: string]: string }) { for (let k of Array.from(prefixes || {})) { this.prefixes[k] = prefixes[k]; } this.prefixFuncs = N3.Util.prefixes(this.prefixes); } U() { // just a shorthand return this.Uri.bind(this); } Uri(curie: string) { if (curie == null) { throw new Error("no uri"); } if (curie.match(/^http/)) { return N3.DataFactory.namedNode(curie); } const part = curie.split(":"); return this.prefixFuncs(part[0])(part[1]); } Literal(jsValue: any) { return N3.DataFactory.literal(jsValue); } LiteralRoundedFloat(f: number) { return N3.DataFactory.literal(d3.format(".3f")(f), this.Uri("http://www.w3.org/2001/XMLSchema#double")); } Quad(s: any, p: any, o: any, g: any) { return N3.DataFactory.quad(s, p, o, g); } toJs(literal: { value: any }) { // incomplete return parseFloat(literal.value); } loadTrig(trig: any, cb: () => any) { // for debugging const patch: Patch = { dels: [], adds: [] }; const parser = new N3.Parser(); return parser.parse(trig, (error: any, quad: any, prefixes: any) => { if (error) { throw new Error(error); } if (quad) { return patch.adds.push(quad); } else { this._applyPatch(patch); this._addPrefixes(prefixes); if (cb) { return cb(); } } }); } quads(): any { // for debugging return Array.from(this.graph.getQuads(null, null, null, null)).map((q: Quad) => [q.subject, q.predicate, q.object, q.graph]); } applyAndSendPatch(patch: Patch) { console.time("applyAndSendPatch"); if (!this._client) { log("not connected-- dropping patch"); return; } if (!Array.isArray(patch.adds) || !Array.isArray(patch.dels)) { console.timeEnd("applyAndSendPatch"); log("corrupt patch"); throw new Error(`corrupt patch: ${JSON.stringify(patch)}`); } this._validatePatch(patch); this._applyPatch(patch); if (this._client) { this._client.sendPatch(patch); } return console.timeEnd("applyAndSendPatch"); } _validatePatch(patch: Patch) { return [patch.adds, patch.dels].map((qs: Quad[]) => (() => { const result = []; for (let q of Array.from(qs)) { if (!q.equals) { throw new Error("doesn't look like a proper Quad"); } if (!q.subject.id || q.graph.id == null || q.predicate.id == null) { throw new Error(`corrupt patch: ${JSON.stringify(q)}`); } else { result.push(undefined); } } return result; })() ); } _applyPatch(patch: Patch) { // In most cases you want applyAndSendPatch. // // This is the only method that writes to this.graph! log("patch from server [1]") this.cachedFloatValues.clear(); this.cachedUriValues.clear(); for (let quad of Array.from(patch.dels)) { //log("remove #{JSON.stringify(quad)}") const did = this.graph.removeQuad(quad); } //log("removed: #{did}") for (let quad of Array.from(patch.adds)) { this.graph.addQuad(quad); } log("applied patch locally", patchSizeSummary(patch)); this._autoDeps.graphChanged(patch); } getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode): Patch { // make a patch which removes existing values for (s,p,*,c) and // adds (s,p,newObject,c). Values in other graphs are not affected. const existing = this.graph.getQuads(s, p, null, g); return { dels: existing, adds: [this.Quad(s, p, newObject, g)], }; } patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode) { this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g)); } clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) { return this.applyAndSendPatch({ dels: this.graph.getQuads(s, p, null, g), adds: [], }); } runHandler(func: HandlerFunc, label: string) { // 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). // helps with memleak? not sure yet. The point was if two matching // labels get puushed on, we should run only one. So maybe // appending a serial number is backwards. if (!this.serial) { this.serial = 1; } this.serial += 1; //label = label + @serial this._autoDeps.runHandler(func, label); } _singleValue(s: Quad_Subject, p: Quad_Predicate) { this._autoDeps.askedFor(s, p, null, null); const quads = this.graph.getQuads(s, p, null, null); const objs = new Set(Array.from(quads).map((q: Quad) => q.object)); switch (objs.size) { case 0: throw new Error("no value for " + s.value + " " + p.value); case 1: var obj = objs.values().next().value; return obj; default: throw new Error("too many different values: " + JSON.stringify(quads)); } } floatValue(s: Quad_Subject, p: Quad_Predicate) { const key = s.value + "|" + p.value; const hit = this.cachedFloatValues.get(key); if (hit !== undefined) { return hit; } //log('float miss', s, p) const v = this._singleValue(s, p).value; const ret = parseFloat(v); if (isNaN(ret)) { throw new Error(`${s.value} ${p.value} -> ${v} not a float`); } this.cachedFloatValues.set(key, ret); return ret; } stringValue(s: any, p: any) { return this._singleValue(s, p).value; } uriValue(s: Quad_Subject, p: Quad_Predicate) { const key = s.value + "|" + p.value; const hit = this.cachedUriValues.get(key); if (hit !== undefined) { return hit; } const ret = this._singleValue(s, p); this.cachedUriValues.set(key, ret); return ret; } labelOrTail(uri: { value: { split: (arg0: string) => any } }) { let ret: any; try { ret = this.stringValue(uri, this.Uri("rdfs:label")); } catch (error) { const words = uri.value.split("/"); ret = words[words.length - 1]; } if (!ret) { ret = uri.value; } return ret; } objects(s: any, p: any): Quad_Object[] { this._autoDeps.askedFor(s, p, null, null); const quads = this.graph.getQuads(s, p, null, null); return Array.from(quads).map((q: { object: any }) => q.object); } subjects(p: any, o: any): Quad_Subject[] { this._autoDeps.askedFor(null, p, o, null); const quads = this.graph.getQuads(null, p, o, null); return Array.from(quads).map((q: { subject: any }) => q.subject); } items(list: any) { const out = []; let current = list; while (true) { if (current === RDF + "nil") { break; } this._autoDeps.askedFor(current, null, null, null); // a little loose const firsts = this.graph.getQuads(current, RDF + "first", null, null); const rests = this.graph.getQuads(current, RDF + "rest", null, 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: any, p: any, o: any): boolean { this._autoDeps.askedFor(s, p, o, null); log("contains calling getQuads when graph has ", this.graph.size); return this.graph.getQuads(s, p, o, null).length > 0; } nextNumberedResources(base: { id: any }, howMany: number) { // base is NamedNode or string // Note this is unsafe before we're synced with the graph. It'll // always return 'name0'. if (base.id) { base = base.id; } const results = []; // @contains is really slow. if (this._nextNumber == null) { this._nextNumber = new Map(); } let start = this._nextNumber.get(base); if (start === undefined) { start = 0; } for (let serial = start, asc = start <= 1000; asc ? serial <= 1000 : serial >= 1000; asc ? serial++ : serial--) { const uri = this.Uri(`${base}${serial}`); if (!this.contains(uri, null, null)) { results.push(uri); log("nextNumberedResources", `picked ${uri}`); this._nextNumber.set(base, serial + 1); if (results.length >= howMany) { return results; } } } throw new Error(`can't make sequential uri with base ${base}`); } nextNumberedResource(base: any) { return this.nextNumberedResources(base, 1)[0]; } contextsWithPattern(s: any, p: any, o: any) { this._autoDeps.askedFor(s, p, o, null); const ctxs = []; for (let q of Array.from(this.graph.getQuads(s, p, o, null))) { ctxs.push(q.graph); } return unique(ctxs); } sortKey(uri: N3.NamedNode) { const parts = uri.value.split(/([0-9]+)/); const expanded = parts.map(function (p: string) { const f = parseInt(p); if (isNaN(f)) { return p; } return p.padStart(8, "0"); }); return expanded.join(""); } sortedUris(uris: any) { return sortBy(uris, this.sortKey); } prettyLiteral(x: any) { if (typeof x === "number") { return this.LiteralRoundedFloat(x); } else { return this.Literal(x); } } }