From ea4babf5f799243d6103e6b61986a4fabfa06773 Mon Sep 17 00:00:00 2001
From: Milho001 <l.milhomemfrancochristino@uu.nl>
Date: Thu, 27 Feb 2025 16:15:25 +0000
Subject: [PATCH] feat: add support for IN and NOT IN logic in query conversion

---
 src/utils/cypher/converter/logic.ts           | 32 ++++++++++-
 .../cypher/converter/queryConverter.test.ts   | 54 ++++++++++++++++++-
 src/utils/cypher/queryParser.ts               | 36 +++++++------
 3 files changed, 102 insertions(+), 20 deletions(-)

diff --git a/src/utils/cypher/converter/logic.ts b/src/utils/cypher/converter/logic.ts
index 584bfbd..4ef8a20 100644
--- a/src/utils/cypher/converter/logic.ts
+++ b/src/utils/cypher/converter/logic.ts
@@ -1,4 +1,4 @@
-import type { AnyStatement } from 'ts-common/src/model/query/logic/general';
+import { StringFilterTypes, type AnyStatement } from 'ts-common/src/model/query/logic/general';
 import type { QueryCacheData } from './model';
 import { log } from 'ts-common/src/logger/logger';
 
@@ -32,7 +32,7 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac
     case 'object':
       if (Array.isArray(logicQuery)) {
         let op = logicQuery[0].replace('_', '').toLowerCase();
-        const right = logicQuery?.[2];
+        let right = logicQuery?.[2];
         const { logic: left, where: whereLogic } = extractLogicCypher(logicQuery[1], cacheData);
 
         switch (op) {
@@ -46,6 +46,34 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac
           case 'like':
             op = '=~';
             break;
+          case 'in':
+          case 'present in list':
+            op = 'IN';
+            if (typeof right === 'string') {
+              return {
+                logic: `(${left} IN [${right
+                  .replace(/"/g, '')
+                  .split(',')
+                  .map((r: string) => `"${r.trim()}"`)
+                  .join(', ')}])`,
+                where: whereLogic,
+              };
+            }
+            break;
+          case 'not in':
+          case 'not in list':
+            op = 'NOT IN';
+            if (typeof right === 'string') {
+              return {
+                logic: `(NOT ${left} IN [${right
+                  .replace(/"/g, '')
+                  .split(',')
+                  .map((r: string) => `"${r.trim()}"`)
+                  .join(', ')}])`,
+                where: whereLogic,
+              };
+            }
+            break;
           case 'isempty':
             return { logic: `(${left} IS NULL OR ${left} = "")`, where: whereLogic };
           case 'isnotempty':
diff --git a/src/utils/cypher/converter/queryConverter.test.ts b/src/utils/cypher/converter/queryConverter.test.ts
index c981a89..5227c6c 100644
--- a/src/utils/cypher/converter/queryConverter.test.ts
+++ b/src/utils/cypher/converter/queryConverter.test.ts
@@ -1,5 +1,5 @@
 import { query2Cypher } from './queryConverter';
-import type { BackendQueryFormat } from 'ts-common';
+import { StringFilterTypes, type BackendQueryFormat } from 'ts-common';
 import { expect, test, describe, it } from 'bun:test';
 
 function fixCypherSpaces(cypher?: string | null): string {
@@ -61,7 +61,7 @@ describe('query2Cypher', () => {
         RETURN * LIMIT 5000`;
     const expectedCypherCount = `MATCH path1 = ((p1:Person)-[:DIRECTED*1..1]->(m1:Movie))
         MATCH path2 = ((p1:Person)-[:IN_GENRE*1..1]->(g1:Genre))
-        RETURN COUNT(p1) as p1_count, COUNT(m1) as m1_count, COUNT(g1) as g1_count`;
+        RETURN COUNT(DISTINCT p1) as p1_count, COUNT(DISTINCT m1) as m1_count, COUNT(DISTINCT g1) as g1_count`;
 
     expect(fixCypherSpaces(cypher.query)).toBe(fixCypherSpaces(expectedCypher));
     expect(fixCypherSpaces(cypher.countQuery)).toBe(fixCypherSpaces(expectedCypherCount));
@@ -663,4 +663,54 @@ describe('query2Cypher', () => {
       'MATCH path_0 = (()-[id_1739982191754:HAS*1..1]-()) RETURN COUNT(DISTINCT id_1739982191754) as id_1739982191754_count';
     expect(fixCypherSpaces(cypher.countQuery)).toEqual(fixCypherSpaces(expectedCypherCount));
   });
+
+  it('should return correctly on a query with IN logic', () => {
+    const query: BackendQueryFormat = {
+      saveStateID: 'test',
+      limit: 500,
+      logic: [StringFilterTypes.IN, '@p1.name', '"John, Doe"'],
+      query: [
+        {
+          id: 'path1',
+          node: {
+            label: 'Person',
+            id: 'p1',
+          },
+        },
+      ],
+      return: ['*'],
+    };
+
+    const cypher = query2Cypher(query);
+    const expectedCypher = `MATCH path1 = ((p1:Person))
+        WHERE (p1.name IN ["John", "Doe"])
+        RETURN * LIMIT 500`;
+
+    expect(fixCypherSpaces(cypher.query)).toBe(fixCypherSpaces(expectedCypher));
+  });
+
+  it('should return correctly on a query with NOT IN logic', () => {
+    const query: BackendQueryFormat = {
+      saveStateID: 'test',
+      limit: 500,
+      logic: [StringFilterTypes.NOT_IN, '@p1.name', '"John, Doe"'],
+      query: [
+        {
+          id: 'path1',
+          node: {
+            label: 'Person',
+            id: 'p1',
+          },
+        },
+      ],
+      return: ['*'],
+    };
+
+    const cypher = query2Cypher(query);
+    const expectedCypher = `MATCH path1 = ((p1:Person))
+        WHERE (NOT p1.name IN ["John", "Doe"])
+        RETURN * LIMIT 500`;
+
+    expect(fixCypherSpaces(cypher.query)).toBe(fixCypherSpaces(expectedCypher));
+  });
 });
diff --git a/src/utils/cypher/queryParser.ts b/src/utils/cypher/queryParser.ts
index 4f25cdd..bb5c9bc 100644
--- a/src/utils/cypher/queryParser.ts
+++ b/src/utils/cypher/queryParser.ts
@@ -13,7 +13,7 @@ import {
   type RecordShape,
 } from 'neo4j-driver';
 import { log } from '../../logger';
-import type { CountQueryResultFromBackend, EdgeQueryResult, NodeQueryResult } from 'ts-common';
+import type { CountQueryResultFromBackend, EdgeQueryResult, NodeAttributes, NodeQueryResult } from 'ts-common';
 import type { GraphQueryResultFromBackend } from 'ts-common';
 
 export function parseCypherQuery(result: RecordShape[], returnType: 'nodelink' | 'table' = 'nodelink'): GraphQueryResultFromBackend {
@@ -42,7 +42,7 @@ export function parseCypherQuery(result: RecordShape[], returnType: 'nodelink' |
 }
 export function parseCountCypherQuery(result: RecordShape[]): CountQueryResultFromBackend {
   try {
-    const countResult: CountQueryResultFromBackend = {};
+    const countResult: CountQueryResultFromBackend = { updatedAt: Date.now() };
     for (let i = 0; i < result.length; i++) {
       const r = result[i];
       for (let j = 0; j < r.keys.length; j++) {
@@ -114,23 +114,27 @@ function parseNodeLinkEntity(
   }
 }
 
+function parseAttributes(attributes: any): NodeAttributes {
+  return Object.fromEntries(
+    Object.entries(attributes)?.map(([k, v]) => {
+      if (Integer.isInteger(v)) {
+        return [k, v.toNumber()];
+      } else if (typeof v === 'string') {
+        return [k, v];
+      } else if (v instanceof Object) {
+        return [k, v.toString()];
+      }
+
+      return [k, v];
+    }),
+  );
+}
+
 function parseNode(node: Node): NodeQueryResult {
   return {
     _id: node.identity.toString(),
     label: node.labels[0], // TODO: only using first label
-    attributes: Object.fromEntries(
-      Object.entries(node.properties)?.map(([k, v]) => {
-        if (Integer.isInteger(v)) {
-          return [k, v.toNumber()];
-        } else if (typeof v === 'string') {
-          return [k, v];
-        } else if (v instanceof Object) {
-          return [k, v.toString()];
-        }
-
-        return [k, v];
-      }),
-    ),
+    attributes: parseAttributes(node.properties),
   };
 }
 
@@ -140,7 +144,7 @@ function parseEdge(edge: Relationship): EdgeQueryResult {
     label: edge.type,
     from: edge.start.toString(),
     to: edge.end.toString(),
-    attributes: { ...edge.properties, type: edge.type },
+    attributes: { ...parseAttributes(edge.properties), type: edge.type },
   };
 }
 
-- 
GitLab