Files @ fe807af851c8
Branch filter:

Location: light9/light9/web/SyncedGraph.ts

drewp@bigasterisk.com
partial port of editchoice
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);
    }
  }
}