changeset 122:2e8fa3fec0c8

support joining subjects into wider rows
author drewp@bigasterisk.com
date Sun, 20 Mar 2022 14:12:03 -0700
parents 3584f24becf4
children ede37954dbb4
files src/layout/Layout.test.ts src/layout/Layout.ts src/layout/ViewConfig.test.ts src/layout/ViewConfig.ts src/layout/algorithm.ts
diffstat 5 files changed, 269 insertions(+), 92 deletions(-) [+]
line wrap: on
line diff
--- a/src/layout/Layout.test.ts	Sun Mar 20 14:10:56 2022 -0700
+++ b/src/layout/Layout.test.ts	Sun Mar 20 14:12:03 2022 -0700
@@ -74,7 +74,7 @@
       const vc = new ViewConfig();
       await vc.readFromGraph(`
         @prefix : <http://example.com/> .
-  
+
         <> a :View; :table [ :primaryType :T1 ] .`);
       const layout = new Layout(vc);
       lr = layout.plan(await typedStatements());
@@ -199,7 +199,104 @@
         { rdfTypes: [EX("T1")], pred: EX("p3") },
         { rdfTypes: [EX("T2")], pred: EX("p2") },
       ]);
-    })
+    });
+    it("joins over an edge", async () => {
+      const vc = new ViewConfig();
+      await vc.readFromGraph(`
+        @prefix : <http://example.com/> .
+
+        <> a :View; :table [
+          :primaryType :T1;
+          :link   [:predicate :predLink ] ] .
+      `);
+      const layout = new Layout(vc);
+      const lr = layout.plan(
+        await n3Graph(`
+        @prefix : <http://example.com/> .
+        :g1 {
+          :a a :T1; :color :red; :predLink :b .
+          :b a :T2; :size :big .
+        }
+      `)
+      );
+
+      expect(lr.sections).toHaveLength(1); // no loose statements
+      const section0 = lr.sections[0] as AlignedTable;
+      expect(section0.rowHeaders).toEqual([EX("a")]);
+      expect(section0.columnHeaders).toEqual([
+        { rdfTypes: [EX("T1")], pred: EX("color") },
+        // :predLink column is hidden
+        { rdfTypes: [EX("T2")], pred: EX("size") },
+      ]);
+    });
   });
+  it("joins 3 subjects into one", async () => {
+    const vc = new ViewConfig();
+    await vc.readFromGraph(`
+      @prefix : <http://example.com/> .
+
+      <> a :View; :table [
+        :primaryType :T1;
+        :link
+          [:predicate :pred1Link ],
+          [:predicate :pred2Link ]
+       ] .
+    `);
+    const layout = new Layout(vc);
+    const lr = layout.plan(
+      await n3Graph(`
+      @prefix : <http://example.com/> .
+      :g1 {
+        :a a :T1; :color :red; :pred1Link :b; :pred2Link :c .
+        :b a :T2; :size :big .
+        :c a :T3; :bright 50 .
+      }
+    `)
+    );
+
+    expect(lr.sections).toHaveLength(1); // no loose statements
+    const section0 = lr.sections[0] as AlignedTable;
+    expect(section0.rowHeaders).toEqual([EX("a")]);
+    expect(section0.columnHeaders).toEqual([
+      { rdfTypes: [EX("T1")], pred: EX("color") },
+      { rdfTypes: [EX("T2")], pred: EX("size") },
+      { rdfTypes: [EX("T3")], pred: EX("bright") },
+    ]);
+  });
+  it("links rows to predicate that themselves are linked in", async () => {
+    const vc = new ViewConfig();
+    await vc.readFromGraph(`
+      @prefix : <http://example.com/> .
+
+      <> a :View; :table [
+        :primaryType :T1;
+        :link
+          [:predicate :pred1Link ],
+          [:predicate :pred2Link ]
+       ] .
+    `);
+    const layout = new Layout(vc);
+    const lr = layout.plan(
+      await n3Graph(`
+      @prefix : <http://example.com/> .
+      :g1 {
+        :a a :T1; :color :red; :pred1Link :b .
+        :b a :T2; :size :big;  :pred2Link :c .
+        :c a :T3; :bright 50 .
+      }
+    `)
+    );
+    console.log(JSON.stringify(lr.sections, null, "  "));
+
+    expect(lr.sections).toHaveLength(1); // no loose statements
+    const section0 = lr.sections[0] as AlignedTable;
+    expect(section0.rowHeaders).toEqual([EX("a")]);
+    expect(section0.columnHeaders).toEqual([
+      { rdfTypes: [EX("T1")], pred: EX("color") },
+      { rdfTypes: [EX("T2")], pred: EX("size") },
+      { rdfTypes: [EX("T3")], pred: EX("bright") },
+    ]);
+  });
+
   it.skip("makes a table out of ungrouped triples with the same type", async () => {});
 });
--- a/src/layout/Layout.ts	Sun Mar 20 14:10:56 2022 -0700
+++ b/src/layout/Layout.ts	Sun Mar 20 14:12:03 2022 -0700
@@ -2,9 +2,10 @@
 
 import Immutable from "immutable"; // mostly using this for the builtin equals() testing, since NamedNode(x)!=NamedNode(x)
 import { NamedNode, Quad, Store, Term } from "n3";
+import { addToValues, multiColumnSort } from "./algorithm";
 import { rdf, rdfs } from "./namespaces";
 import { uniqueSortedTerms, UriPairMap } from "./rdf_value";
-import { ViewConfig } from "./ViewConfig";
+import { Link, ViewConfig } from "./ViewConfig";
 
 type UriSet = Immutable.Set<NamedNode>;
 
@@ -37,61 +38,18 @@
   sections: (AlignedTable | FreeStatements)[];
 }
 
-function addToValues<K, V>(
-  imap: Immutable.Map<K, V[]>,
-  key: K,
-  newValue: V
-): Immutable.Map<K, V[]> {
-  let cur = imap.get(key, undefined);
-  let ret = imap;
-  if (cur === undefined) {
-    cur = [];
-    ret = imap.set(key, cur);
-  }
-  cur.push(newValue);
-  return ret;
-}
-
-function multiColumnSort<T>(
-  elems: T[],
-  makeSortCols: (elem: T, index: number) => Array<number | string>
-): T[] {
-  const tagged = elems.map((p, i) => {
-    return {
-      sort: makeSortCols(p, i),
-      val: p,
-    };
-  });
-  tagged.sort((e1, e2) => {
-    let index = 0;
-    for (let k1 of e1.sort) {
-      const k2 = e2.sort[index];
-      if (!Immutable.is(k1, k2)) {
-        if (typeof k1 === "number") {
-          if (typeof k2 === "number") {
-            return k1 - k2;
-          } else {
-            throw new Error(`${k1} vs ${k2}`);
-          }
-        } else {
-          return k1.localeCompare(k2 as string);
-        }
-      }
-      index++;
-    }
-    return 0;
-  });
-  return tagged.map((e) => e.val);
-}
-
 class AlignedTableBuilder {
   subjSet: UriSet = Immutable.Set();
   predSet: UriSet = Immutable.Set();
   subjsSeenWithPred: Immutable.Map<NamedNode, NamedNode[]> = Immutable.Map();
   typesSeenForSubj: Immutable.Map<NamedNode, NamedNode[]> = Immutable.Map();
-
+  aliases: Immutable.Map<NamedNode, NamedNode> = Immutable.Map();
   cell = new UriPairMap();
-  constructor(public primaryType: NamedNode, public joinTypes: NamedNode[]) {}
+  constructor(
+    public primaryType: NamedNode,
+    public joinTypes: NamedNode[],
+    public links: Link[]
+  ) {}
 
   showsType(rdfType: NamedNode): boolean {
     return (
@@ -100,26 +58,54 @@
     );
   }
 
+  // Considering this.links, call adder on more subjs that should be routed to this table.
+  detectLinks(graph: Store, adder: (linkedSubj: NamedNode) => void) {
+    this.links.forEach((link: Link) => {
+      graph.forEach(
+        (q: Quad) => {
+          const linkedSubj = q.object as NamedNode;
+          adder(linkedSubj);
+          this.linkSubjs(q.subject as NamedNode, linkedSubj);
+        },
+        null,
+        link.pred,
+        null,
+        null
+      );
+    });
+    this.chainLinks();
+  }
+
+  // When you make a row for displayedSubj, also include linkedSubj's data
+  private linkSubjs(displayedSubj: NamedNode, linkedSubj: NamedNode) {
+    this.aliases = this.aliases.set(linkedSubj, displayedSubj);
+  }
+
+  // After this.aliases is built; shorten {b: a, c:b} to {b: a, c: a}
+  private chainLinks() {
+    for (let alias of this.aliases.keys()) {
+      let x = this.aliases.get(alias)!;
+      while (this.aliases.has(x)) {
+        x = this.aliases.get(x)!;
+      }
+      this.aliases = this.aliases.set(alias, x);
+    }
+  }
+
+  // Stream in quads that belong to this table (caller has to work out that
+  // part), then call value().
   addQuad(q: Quad) {
-    const subj = q.subject as NamedNode;
+    const unaliasedSubj = q.subject as NamedNode;
+    const aliasedSubj = this.aliases.get(unaliasedSubj, unaliasedSubj);
     const pred = q.predicate as NamedNode;
-    this.subjSet = this.subjSet.add(subj);
+    this.subjSet = this.subjSet.add(aliasedSubj);
     this.predSet = this.predSet.add(pred);
-    this.cell.add(subj, pred, q.object);
+    this.cell.add(aliasedSubj, pred, q.object);
 
     if (pred.equals(rdf.type)) {
-      this.trackTypes(subj, q.object as NamedNode);
+      this.trackTypes(unaliasedSubj, q.object as NamedNode);
     }
-    this.trackSubjs(subj, pred);
-  }
-
-  private trackTypes(subj: NamedNode<string>, rdfType: NamedNode) {
-    let cur = this.typesSeenForSubj.get(subj, undefined);
-    if (cur === undefined) {
-      cur = [];
-      this.typesSeenForSubj = this.typesSeenForSubj.set(subj, cur);
-    }
-    cur.push(rdfType);
+    this.trackSubjs(unaliasedSubj, pred);
   }
 
   private trackTypes(unaliasedSubj: NamedNode<string>, rdfType: NamedNode) {
@@ -138,10 +124,11 @@
     );
   }
 
-  _displayedPreds(): NamedNode[] {
+  private displayedPreds(): NamedNode[] {
     let preds = uniqueSortedTerms(this.predSet);
     preds = preds.filter((p) => {
       if (p.equals(rdf.type)) return false;
+      if (this.links.filter((l) => l.pred.equals(p)).length) return false;
       return true;
     });
     preds = multiColumnSort(preds, (elem: NamedNode, index: number) => {
@@ -172,10 +159,8 @@
     return uniqueSortedTerms(types);
   }
 
-  value(): AlignedTable {
-    const subjs = uniqueSortedTerms(this.subjSet);
-    const preds = this._displayedPreds();
-    const outputGrid: Term[][][] = [];
+  private gatherOutputGrid(subjs: NamedNode[], preds: NamedNode[]): Term[][][] {
+    const out: Term[][][] = [];
     for (let subj of subjs) {
       const row: Term[][] = [];
       preds.forEach((pred) => {
@@ -183,13 +168,20 @@
         const uniq = uniqueSortedTerms(objs);
         row.push(uniq);
       });
-      outputGrid.push(row);
+      out.push(row);
     }
+    return out;
+  }
 
-    const headers: ColumnHeader[] = preds.map((pred) => {
+  value(): AlignedTable {
+    const subjs = uniqueSortedTerms(this.subjSet);
+    const preds = this.displayedPreds();
+    const outputGrid: Term[][][] = this.gatherOutputGrid(subjs, preds);
+
+    const colHeaders: ColumnHeader[] = preds.map((pred) => {
       return { rdfTypes: this.typesSeenWithPred(pred), pred: pred };
     });
-    return { columnHeaders: headers, rowHeaders: subjs, rows: outputGrid };
+    return { columnHeaders: colHeaders, rowHeaders: subjs, rows: outputGrid };
   }
 }
 
@@ -218,6 +210,13 @@
     null,
     null
   );
+
+  tableBuilders.forEach((tb) => {
+    tb.detectLinks(graph, (linkedSubj: NamedNode) => {
+      out = addToValues(out, linkedSubj, tb);
+    });
+  });
+
   return out;
 }
 
@@ -251,7 +250,7 @@
 export class Layout {
   constructor(public viewConfig?: ViewConfig) {}
 
-  _groupAllStatements(
+  private groupAllStatements(
     graph: Store,
     tablesWantingSubject: SubjectTableBuilders,
     ungrouped: Quad[]
@@ -273,26 +272,31 @@
     );
   }
 
-  plan(graph: Store): LayoutResult {
-    const ungrouped: Quad[] = [];
-
-    const tableBuilders = this.viewConfig
-      ? this.viewConfig.tables.map(
-          (t) => new AlignedTableBuilder(t.primary, t.joins)
-        )
-      : [];
-
-    const tablesWantingSubject = subjectsToTablesMap(graph, tableBuilders);
-    this._groupAllStatements(graph, tablesWantingSubject, ungrouped);
-    const res: LayoutResult = { sections: [] };
+  private generateSections(
+    tableBuilders: AlignedTableBuilder[],
+    ungrouped: Quad[]
+  ): (AlignedTable | FreeStatements)[] {
+    const sections = [];
     for (const t of tableBuilders) {
       if (t.gotStatements()) {
-        res.sections.push(t.value());
+        sections.push(t.value());
       }
     }
     if (ungrouped.length) {
-      res.sections.push(freeStatmentsSection(ungrouped));
+      sections.push(freeStatmentsSection(ungrouped));
     }
-    return res;
+    return sections;
+  }
+
+  plan(graph: Store): LayoutResult {
+    const vcTables = this.viewConfig ? this.viewConfig.tables : [];
+    const tableBuilders = vcTables.map(
+      (t) => new AlignedTableBuilder(t.primary, t.joins, t.links)
+    );
+
+    const tablesWantingSubject = subjectsToTablesMap(graph, tableBuilders);
+    const ungrouped: Quad[] = [];
+    this.groupAllStatements(graph, tablesWantingSubject, ungrouped);
+    return { sections: this.generateSections(tableBuilders, ungrouped) };
   }
 }
--- a/src/layout/ViewConfig.test.ts	Sun Mar 20 14:10:56 2022 -0700
+++ b/src/layout/ViewConfig.test.ts	Sun Mar 20 14:12:03 2022 -0700
@@ -1,4 +1,5 @@
 import { Util } from "n3";
+import { EX } from "./namespaces";
 import { ViewConfig } from "./ViewConfig";
 
 describe("ViewModel", () => {
@@ -13,7 +14,7 @@
 
         <> a ex:View ; rdfs:label "repos" .
         <> ex:table demo:table1 .
-        demo:table1 
+        demo:table1
         ex:primaryType :FilteredNic;
         ex:joinType :Traffic .
         `);
@@ -22,4 +23,18 @@
     expect(vc.tables[0].primary).toEqual(NET("FilteredNic"));
     expect(vc.tables[0].joins).toEqual([NET("Traffic")]);
   });
+  it("gets pred joins", async () => {
+    const vc = new ViewConfig();
+    await vc.readFromGraph(`
+    @prefix ex: <http://example.com/> .
+    @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+    @prefix : <http://example.com/> .
+
+    <> a :View; :table [
+      :primaryType :T1;
+      :link [:predicate :predLink; :rtype :T2 ] ] .
+  `);
+    expect(vc.tables[0].primary).toEqual(EX("T1"));
+    expect(vc.tables[0].links).toEqual([{ pred: EX("predLink") }]);
+  });
 });
--- a/src/layout/ViewConfig.ts	Sun Mar 20 14:10:56 2022 -0700
+++ b/src/layout/ViewConfig.ts	Sun Mar 20 14:12:03 2022 -0700
@@ -12,10 +12,16 @@
   throw new Error("no elems");
 }
 
-export interface TableDesc {
+export interface Link {
+  // If you display a subject u1 with a `pred` edge to u2, then treat u2 as an alias of u1.
+  pred: NamedNode;
+}
+
+interface TableDesc {
   uri: NamedNode;
   primary: NamedNode;
   joins: NamedNode[];
+  links: Link[];
 }
 
 export class ViewConfig {
@@ -54,10 +60,19 @@
         joins.push(joinType as NamedNode);
       }
       joins.sort();
+
+      const links: Link[] = [];
+      for (let linkDesc of this.graph.getObjects(table, EX("link"), null)) {
+        links.push({
+          pred: uriValue(this.graph, linkDesc, EX("predicate")),
+        });
+      }
+
       this.tables.push({
         uri: table as NamedNode,
         primary: tableType,
         joins: joins,
+        links: links,
       });
     }
     this.tables.sort();
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/layout/algorithm.ts	Sun Mar 20 14:12:03 2022 -0700
@@ -0,0 +1,46 @@
+import Immutable from "immutable";
+
+export function addToValues<K, V>(
+  imap: Immutable.Map<K, V[]>,
+  key: K,
+  newValue: V): Immutable.Map<K, V[]> {
+  let cur = imap.get(key, undefined);
+  let ret = imap;
+  if (cur === undefined) {
+    cur = [];
+    ret = imap.set(key, cur);
+  }
+  cur.push(newValue);
+  return ret;
+}
+
+export function multiColumnSort<T>(
+  elems: T[],
+  makeSortCols: (elem: T, index: number) => Array<number | string>): T[] {
+  const tagged = elems.map((p, i) => {
+    return {
+      sort: makeSortCols(p, i),
+      val: p
+    };
+  });
+  tagged.sort((e1, e2) => {
+    let index = 0;
+    for (let k1 of e1.sort) {
+      const k2 = e2.sort[index];
+      if (!Immutable.is(k1, k2)) {
+        if (typeof k1 === "number") {
+          if (typeof k2 === "number") {
+            return k1 - k2;
+          } else {
+            throw new Error(`${k1} vs ${k2}`);
+          }
+        } else {
+          return k1.localeCompare(k2 as string);
+        }
+      }
+      index++;
+    }
+    return 0;
+  });
+  return tagged.map((e) => e.val);
+}