From 99cf6021602fb786bdf52108c0220115ce266d2c Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Mon, 24 Feb 2025 17:53:59 +0100 Subject: [PATCH 1/4] feat: add support for IN and NOT IN logic in query conversion --- src/utils/cypher/converter/logic.ts | 28 ++++++++++- .../cypher/converter/queryConverter.test.ts | 50 +++++++++++++++++++ src/utils/cypher/queryParser.ts | 2 +- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/utils/cypher/converter/logic.ts b/src/utils/cypher/converter/logic.ts index 584bfbd..fe604c8 100644 --- a/src/utils/cypher/converter/logic.ts +++ b/src/utils/cypher/converter/logic.ts @@ -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,32 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac case 'like': op = '=~'; break; + case 'in': + 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': + 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..c31ae7b 100644 --- a/src/utils/cypher/converter/queryConverter.test.ts +++ b/src/utils/cypher/converter/queryConverter.test.ts @@ -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: ['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: ['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..17a7f9d 100644 --- a/src/utils/cypher/queryParser.ts +++ b/src/utils/cypher/queryParser.ts @@ -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++) { -- GitLab From bbf548f69bc815d9c3c955b7ba9daa1e20339041 Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Mon, 24 Feb 2025 17:58:39 +0100 Subject: [PATCH 2/4] fix: parse correctly relation property values --- src/utils/cypher/queryParser.ts | 34 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/utils/cypher/queryParser.ts b/src/utils/cypher/queryParser.ts index 17a7f9d..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 { @@ -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 From d4aeb92d937c33d739f6ad4e7359326e6c10fccd Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Wed, 26 Feb 2025 11:22:18 +0100 Subject: [PATCH 3/4] feat: update logic handling for 'in' and 'not in' filters and improve query distinct counting --- src/utils/cypher/converter/logic.ts | 6 +++--- src/utils/cypher/converter/queryConverter.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utils/cypher/converter/logic.ts b/src/utils/cypher/converter/logic.ts index fe604c8..0d21487 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'; @@ -46,7 +46,7 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac case 'like': op = '=~'; break; - case 'in': + case 'present in list': op = 'IN'; if (typeof right === 'string') { return { @@ -59,7 +59,7 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac }; } break; - case 'not in': + case 'not in list': op = 'NOT IN'; if (typeof right === 'string') { return { diff --git a/src/utils/cypher/converter/queryConverter.test.ts b/src/utils/cypher/converter/queryConverter.test.ts index c31ae7b..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)); @@ -668,7 +668,7 @@ describe('query2Cypher', () => { const query: BackendQueryFormat = { saveStateID: 'test', limit: 500, - logic: ['In', '@p1.name', '"John, Doe"'], + logic: [StringFilterTypes.IN, '@p1.name', '"John, Doe"'], query: [ { id: 'path1', @@ -693,7 +693,7 @@ describe('query2Cypher', () => { const query: BackendQueryFormat = { saveStateID: 'test', limit: 500, - logic: ['Not In', '@p1.name', '"John, Doe"'], + logic: [StringFilterTypes.NOT_IN, '@p1.name', '"John, Doe"'], query: [ { id: 'path1', @@ -708,7 +708,7 @@ describe('query2Cypher', () => { const cypher = query2Cypher(query); const expectedCypher = `MATCH path1 = ((p1:Person)) - WHERE (NOT p1.name IN ["John, Doe"]) + WHERE (NOT p1.name IN ["John", "Doe"]) RETURN * LIMIT 500`; expect(fixCypherSpaces(cypher.query)).toBe(fixCypherSpaces(expectedCypher)); -- GitLab From 0b70070c19e520afdd2452eb6613507fe39575fc Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Thu, 27 Feb 2025 11:52:20 +0100 Subject: [PATCH 4/4] chore: add support for 'in' and 'not in' operators in logic extraction --- src/utils/cypher/converter/logic.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/cypher/converter/logic.ts b/src/utils/cypher/converter/logic.ts index 0d21487..4ef8a20 100644 --- a/src/utils/cypher/converter/logic.ts +++ b/src/utils/cypher/converter/logic.ts @@ -46,6 +46,7 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac case 'like': op = '=~'; break; + case 'in': case 'present in list': op = 'IN'; if (typeof right === 'string') { @@ -59,6 +60,7 @@ export function extractLogicCypher(logicQuery: AnyStatement, cacheData: QueryCac }; } break; + case 'not in': case 'not in list': op = 'NOT IN'; if (typeof right === 'string') { -- GitLab