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