From 89ad4dd9ab15b04be09462c8adee395034f36339 Mon Sep 17 00:00:00 2001
From: Philippe Bordron <philippe.bordron@inrae.fr>
Date: Fri, 11 Oct 2024 17:22:41 +0200
Subject: [PATCH] feat: Transforms MDPage into Document en allow to select some

- flatten documents hierachy
- add types to be able to select some subsets
---
 server/resolvers/documents.js              |  36 ++--
 server/schema.js                           |   4 +-
 server/schema/{md_page.js => documents.js} |  12 +-
 server/utils/resolvers/documents.js        | 218 +++++++++++++++++++++
 test/utilsTests/resolvers/eval.js          | 137 +++++++++++++
 5 files changed, 384 insertions(+), 23 deletions(-)
 rename server/schema/{md_page.js => documents.js} (58%)
 create mode 100644 server/utils/resolvers/documents.js
 create mode 100644 test/utilsTests/resolvers/eval.js

diff --git a/server/resolvers/documents.js b/server/resolvers/documents.js
index 328f118..8e57e53 100644
--- a/server/resolvers/documents.js
+++ b/server/resolvers/documents.js
@@ -1,4 +1,5 @@
 import fs from 'node:fs/promises';
+import {filterData} from '../utils/resolvers/documents.js';
 
 async function read_md(path){
     try {
@@ -9,30 +10,39 @@ async function read_md(path){
     }
 }
 
-function get_legals(){
+async function getDocuments(){
     return [{
             id: "legal_notices",
             menu_label: "Legal Notices",
-            content: read_md('./documents/legal/legal_notices.md')
-        },{
+            content: await read_md('./documents/legal/legal_notices.md'),
+            types: ["legal"]
+    }, {
             id: "privacy_policy",
             menu_label: "Privacy Policy",
-            content: read_md('./documents/legal/privacy_policy.md')
-        },{
+            content: await read_md('./documents/legal/privacy_policy.md'),
+            types: ["legal"]
+    }, {
             id: "terms_of_use",
             menu_label: "Terms of Use",
-            content: read_md('./documents/legal/terms_of_use.md')
-        }]
+            content: await read_md('./documents/legal/terms_of_use.md'),
+            types: ["legal"]
+    }]
+}
+
+async function selectDocuments(where=null) {
+    console.log(where)
+    let result = await getDocuments()
+    if (where !== null) {
+        result = filterData(where, result)
+    }
+    return result
 }
 
 export const resolvers = {
     Query: {
-        documents: (parent, args, contextValue, info) => {
-            return [{
-                'legals': get_legals()
-        }]},
-        mdPages: (parent, args, contextValue, info) => {
-            return [...get_legals()]
+        documents: async (parent, args, contextValue, info) => {
+            //console.log(args) // 'variable values:' { where: { types_INCLUDES: 'legal' } }
+            return selectDocuments(args.where)
         },
         
     }
diff --git a/server/schema.js b/server/schema.js
index 9258c16..7e96991 100644
--- a/server/schema.js
+++ b/server/schema.js
@@ -14,7 +14,7 @@ import { typeDefs as groupDefs } from './schema/guide_group.js'
 import { typeDefs as clusterDefs } from './schema/cluster.js'
 import { typeDefs as isoformDefs } from './schema/isoform.js'
 import { typeDefs as entryDefs } from './schema/table_entry.js'
-import { typeDefs as mdPageDefs } from './schema/md_page.js'
+import { typeDefs as documentsDefs } from './schema/documents.js'
 
 const schemaDef = gql`
 extend schema @query(read: true, aggregate: true) @mutation(operations: [])
@@ -36,7 +36,7 @@ const typeDefs = [
     groupDefs,
     clusterDefs,
     isoformDefs,
-    mdPageDefs,
+    documentsDefs,
     schemaDef
 ]
 
diff --git a/server/schema/md_page.js b/server/schema/documents.js
similarity index 58%
rename from server/schema/md_page.js
rename to server/schema/documents.js
index b294bf2..78df53a 100644
--- a/server/schema/md_page.js
+++ b/server/schema/documents.js
@@ -2,9 +2,9 @@ import { gql } from 'graphql-tag'
 
 export const typeDefs = gql`
 """
-A Page in Markdown Format
+A Document in Markdown Format
 """
-type MDPage {
+type Document {
 
     id: ID!
 
@@ -13,12 +13,8 @@ type MDPage {
 
     "The page content"
     content: String!
-}
-
-type Documents {
-
-    "The list of legal pages"
-    legals: [MDPage!] @customResolver
 
+    "The type/group/kind of document"
+    types: [String]
 }
 `
\ No newline at end of file
diff --git a/server/utils/resolvers/documents.js b/server/utils/resolvers/documents.js
new file mode 100644
index 0000000..070753c
--- /dev/null
+++ b/server/utils/resolvers/documents.js
@@ -0,0 +1,218 @@
+/**
+ * Class representing an evaluation node.
+ * @abstract
+ */
+
+class EvalNode {
+
+    /**
+     * Check if the record satisfied the conditional subtree.
+     * @param {Object} record - a GraphQL object
+     * @returns {boolean} true if the conditions are satisfied, false else
+     * @memberof EvalNode
+     */
+    valid(record){
+        throw new Error("Method 'valid(record)' must be implemented.");
+    }
+}
+
+/**
+ * Class representing an evaluation node that compare a field with a value.
+ * @extends EvalNode
+ */
+class ComparisonLeaf extends EvalNode {
+
+    /**
+     * Create a node that compare a field with a value
+     * @constructor
+     * @param {function} evalFunc - The comparison function.
+     * @param {string} field - The GraphQL field in record that will be compared.
+     * @param {*} value - The compared value
+     * @memberof ComparisonLeaf
+     */
+    constructor(evalFunc, field, value) {
+        super()
+        this.evalFunc = evalFunc
+        this.field = field;
+        this.value = value;
+    }
+    
+    /**
+     * Check if the record satisfied the comparison.
+     * @param {Object} record - a GraphQL object
+     * @returns {boolean} true if the conditions are satisfied, false else
+     * @memberof ComparisonLeaf
+     */
+    valid(record){
+        return this.evalFunc(record[this.field], this.value);
+    }
+}
+
+
+/**
+ * Class representing logical operators between many conditions.
+ * @extends EvalNode
+ */
+class LogicalNode extends EvalNode {
+
+    /**
+     * Create a LogicalNode
+     * @constructor
+     */
+    constructor() {
+        super()
+        this.children = [];
+    }
+
+    /**
+     * Add a child to node
+     * @param {EvalNode} child
+     * @memberof LogicalNode
+     */
+    addChild(child){
+        this.children.push(child);
+    }
+}
+
+/**
+ * Class representing the AND operator.
+ * @extends LogicalNode
+ */
+class ANDNode extends LogicalNode {
+
+    valid(record){
+        return this.children.every((obj) => obj.valid(record))
+    }
+}
+
+/**
+ * Class representing the OR operator.
+ * @extends LogicalNode
+ */
+class ORNode extends LogicalNode {
+
+    valid(record){
+        return this.children.some((obj) => obj.valid(record))
+    }
+}
+
+/**
+ * Class representing the NOT operator.
+ * @extends LogicalNode
+ */
+class NOTNode extends LogicalNode {
+
+    valid(record){
+        return ! this.children[0].valid(record)
+    }
+}
+
+/**
+ * Split the property name from Where Object into an operator and the GraphQL field it is applied
+ * @param {string} propName - The property name
+ * @returns {string[]} A couple composed of (1) the operator key, (2) the GraphQL Object property concerned
+ */
+export function getOperatorAndField(propName) {
+    const regexpLogical = /^(AND|OR|NOT)$/
+    const regexpComp = /(.*?)_(GT|GTE|IN|LT|LTE|CONTAINS|INCLUDES|ENDS_WITH|STARTS_WITH)$/;
+    let m = RegExp(regexpLogical).exec(propName)
+    if (m !== null){
+        // no fields with boolean operators
+        return [m[0], null]
+    } else {
+        m = RegExp(regexpComp).exec(propName)
+        if (m !== null){
+            return m.slice(1,3).reverse()
+        }
+    }
+    return ['EQUAL', propName]
+}
+
+/**
+ * A mapping between the operator key and the evaluation function
+ */
+const evalComp = {
+    GT : (a, b) => a > b,
+    GTE : (a, b) => a >= b,
+    LT : (a, b) => a < b,
+    LTE : (a, b) => a <= b,
+    ENDS_WITH : (a, b) => a.endsWith(b),
+    STARTS_WITH : (a, b) => a.startsWith(b),
+    CONTAINS : (a, b) => a.includes(b),
+    INCLUDES : (a, b) => a.includes(b),
+    EQUAL : (a, b) => a == b
+}
+
+/**
+ * Create a Node according to the GraphQL Where property and associated value
+ * @param {*} key - The neo4j's GraphQL Where Operation (e.g. id_CONTAINS, size_GT, OR, etc.)
+ * @param {*} val - The value used with operation.
+ * @returns {EvalNode} The evalution (sub)tree.
+ */
+function createNode(key, val) {
+    let [op, field] = getOperatorAndField(key)
+    let node
+    switch (op) {
+        case 'AND':
+            node = new ANDNode()
+            if (! Array.isArray(val)) {
+                throw new Error("Expected Array.");
+            }
+            for (const elem of val) {
+                node.addChild(createTree(elem))
+            }
+        break;
+        case 'OR':
+            node = new ORNode()
+            if (! Array.isArray(val)) {
+                throw new Error("Expected Array.");
+            }
+            for (const elem of val) {
+                node.addChild(createTree(elem))
+            }
+        break;
+        case 'NOT':
+            node = new NOTNode()
+            node.addChild(createTree(val))
+        break;
+        default:
+            node = new ComparisonLeaf(evalComp[op], field, val)
+        break; 
+    }
+    return node
+}
+
+
+/**
+ * Create an evaltuation tree that can be used to check if record satisfies a neo4j's GraphQL where object
+ * @param {Object} where - a neo4j's GraphQL where object
+ * @returns {EvalNode} The evalution tree.
+ */
+function createTree(where){
+    let tree
+    if (typeof where === 'object' && ! Array.isArray(where) && where !== null){
+        if (Object.entries(where).size > 1){
+            // More than one conditional property -> all must be satisfied
+            tree = new ANDNode('AND')
+            for (const [key, val] of Object.entries(where)) {
+                tree.addChild(createNode(key, val))
+            }
+        } else {
+            const [key, val] = Object.entries(where)[0]
+            tree = createNode(key, val)
+        }
+    }
+    return tree
+}
+
+/**
+ * Filter a GraphQL Answer
+ * @param {Object} where - a neo4j GraphQL Where object
+ * @param {Object[]} data - the array of GraphQL object to filter
+ * @returns 
+ */
+
+export function filterData(where, data) {
+    const tree = createTree(where)
+    return data.filter((record) => tree.valid(record));
+}
diff --git a/test/utilsTests/resolvers/eval.js b/test/utilsTests/resolvers/eval.js
new file mode 100644
index 0000000..00635b4
--- /dev/null
+++ b/test/utilsTests/resolvers/eval.js
@@ -0,0 +1,137 @@
+import { filterData } from "../../../server/utils/resolvers/documents.js";
+import { describe, it } from "node:test";
+import assert from "node:assert";
+
+const data = [
+     {
+          "id": "legal_notices",
+          "menu_label": "Legal Notices",
+          "types": [
+               "legal"
+          ]
+     },
+     {
+          "id": "privacy_policy",
+          "menu_label": "Privacy Policy",
+          "types": [
+               "legal",
+               "illegal"
+          ]
+     },
+     {
+          "id": "terms_of_use",
+          "menu_label": "Terms of Use",
+          "types": [
+               "illegal"
+          ]
+     }
+]
+
+const testEqual = { id: 'privacy_policy' }
+
+const expectedResultEqual = [
+     {
+          "id": "privacy_policy",
+          "menu_label": "Privacy Policy",
+          "types": [
+               "legal",
+               "illegal"
+          ]
+     }
+]
+
+describe("EQUAL: filterData function", () => {
+     it('Test Equal', () => {
+          assert.deepStrictEqual(filterData(testEqual, data), expectedResultEqual);
+     });
+});
+
+const testEqual2 = { id: 'privacy_policy', "menu_label": "Privacy Policy"}
+
+describe("EQUAL: filterData function", () => {
+     it('Test Equal 2', () => {
+          assert.deepStrictEqual(filterData(testEqual2, data), expectedResultEqual);
+     });
+});
+
+const testINCLUDES = { types_INCLUDES: 'legal' }
+
+const expectedResultINCLUDES = [
+     {
+          "id": "legal_notices",
+          "menu_label": "Legal Notices",
+          "types": [
+               "legal"
+          ]
+     },
+     {
+          "id": "privacy_policy",
+          "menu_label": "Privacy Policy",
+          "types": [
+               "legal",
+               "illegal"
+          ]
+     }
+]
+
+describe("INCLUDES: filterData function", () => {
+     it('Test filterData INCLUDES', () => {
+          assert.deepStrictEqual(filterData(testINCLUDES, data), expectedResultINCLUDES);
+     });
+});
+
+const testNOT = { NOT: { id_CONTAINS: "legal" }}
+
+const expectedResultNOT = [
+     {
+          "id": "privacy_policy",
+          "menu_label": "Privacy Policy",
+          "types": [
+               "legal",
+               "illegal"
+          ]
+     },
+     {
+          "id": "terms_of_use",
+          "menu_label": "Terms of Use",
+          "types": [
+               "illegal"
+          ]
+     }
+]
+
+describe("filterData function", () => {
+     it('Test filterData NOT', () => {
+          assert.deepStrictEqual(filterData(testNOT, data), expectedResultNOT);
+     });
+});
+
+const testNOT2 = { NOT: { types_CONTAINS: "illegal" }}
+
+const expectedResultNOT2 = [
+     {
+          "id": "legal_notices",
+          "menu_label": "Legal Notices",
+          "types": [
+               "legal"
+          ]
+     }
+]
+
+
+
+describe("filterData function", () => {
+     it('Test filterData NOT2', () => {
+          assert.deepStrictEqual(filterData(testNOT2, data), expectedResultNOT2);
+     });
+});
+
+const testOR = { OR: [{ id: 'privacy_policy' }, { types_INCLUDES: 'illegal' }] }
+//const testOR = { OR: [{ id: 'privacy_policy' }, { id: 'terms_of_use' }] }
+
+
+describe("filterData function", () => {
+     it('Test filterData OR', () => {
+          assert.deepStrictEqual(filterData(testOR, data), expectedResultNOT);
+     });
+});
-- 
GitLab