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