From 36b497d7cc5da2bb09abf28e46c2630c66f6eb21 Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Fri, 21 Feb 2025 17:05:50 +0100 Subject: [PATCH] feat: postgres --- bun.lockb | Bin 150175 -> 158213 bytes package.json | 8 +- .../arangodb/golang/README.md | 0 .../arangodb/golang/executeQuery.go | 0 .../arangodb/golang/executeQuery_test.go | 0 src/queryExecution/converter.ts | 14 + .../cypher/converter/export.ts | 0 .../cypher/converter/filter.ts | 0 .../cypher/converter/index.ts | 0 .../cypher/converter/logic.ts | 0 .../cypher/converter/model.ts | 0 .../cypher/converter/node.ts | 0 .../cypher/converter/queryConverter.test.ts | 0 .../cypher/converter/queryConverter.ts | 9 +- .../cypher/converter/relation.ts | 0 .../cypher/queryResultParser.ts} | 4 +- src/{utils => queryExecution}/hashing.ts | 0 src/{utils => queryExecution}/insights.ts | 0 src/{utils => queryExecution}/lexical.ts | 0 src/queryExecution/model.ts | 4 + .../queryPublisher.ts | 0 .../reactflow/query2backend.ts | 2 +- .../sparql/golang/README.md | 0 .../sparql/golang/entity/result.go | 0 .../sparql/golang/executeQuery.go | 0 .../sparql/golang/executeQuery_test.go | 0 .../sql/index.ts} | 0 .../sql/queryConverterSql.test.ts | 538 ++++++++++++++++++ src/queryExecution/sql/queryConverterSql.ts | 233 ++++++++ src/queryExecution/sql/queryResultParser.ts | 121 ++++ src/readers/diffCheck.ts | 2 +- src/readers/insightProcessor.ts | 12 +- src/readers/queryService.ts | 106 +--- src/readers/services/cache.ts | 16 + src/readers/services/cypherService.ts | 57 ++ src/readers/services/index.ts | 14 + src/readers/services/sqlService.ts | 63 ++ 37 files changed, 1101 insertions(+), 102 deletions(-) rename src/{utils => queryExecution}/arangodb/golang/README.md (100%) rename src/{utils => queryExecution}/arangodb/golang/executeQuery.go (100%) rename src/{utils => queryExecution}/arangodb/golang/executeQuery_test.go (100%) create mode 100644 src/queryExecution/converter.ts rename src/{utils => queryExecution}/cypher/converter/export.ts (100%) rename src/{utils => queryExecution}/cypher/converter/filter.ts (100%) rename src/{utils => queryExecution}/cypher/converter/index.ts (100%) rename src/{utils => queryExecution}/cypher/converter/logic.ts (100%) rename src/{utils => queryExecution}/cypher/converter/model.ts (100%) rename src/{utils => queryExecution}/cypher/converter/node.ts (100%) rename src/{utils => queryExecution}/cypher/converter/queryConverter.test.ts (100%) rename src/{utils => queryExecution}/cypher/converter/queryConverter.ts (96%) rename src/{utils => queryExecution}/cypher/converter/relation.ts (100%) rename src/{utils/cypher/queryParser.ts => queryExecution/cypher/queryResultParser.ts} (95%) rename src/{utils => queryExecution}/hashing.ts (100%) rename src/{utils => queryExecution}/insights.ts (100%) rename src/{utils => queryExecution}/lexical.ts (100%) create mode 100644 src/queryExecution/model.ts rename src/{utils => queryExecution}/queryPublisher.ts (100%) rename src/{utils => queryExecution}/reactflow/query2backend.ts (99%) rename src/{utils => queryExecution}/sparql/golang/README.md (100%) rename src/{utils => queryExecution}/sparql/golang/entity/result.go (100%) rename src/{utils => queryExecution}/sparql/golang/executeQuery.go (100%) rename src/{utils => queryExecution}/sparql/golang/executeQuery_test.go (100%) rename src/{utils/cypher/queryTranslator.ts => queryExecution/sql/index.ts} (100%) create mode 100644 src/queryExecution/sql/queryConverterSql.test.ts create mode 100644 src/queryExecution/sql/queryConverterSql.ts create mode 100644 src/queryExecution/sql/queryResultParser.ts create mode 100644 src/readers/services/cache.ts create mode 100644 src/readers/services/cypherService.ts create mode 100644 src/readers/services/index.ts create mode 100644 src/readers/services/sqlService.ts diff --git a/bun.lockb b/bun.lockb index 94886d7ec58aab76974728e32acbce8e5b53710b..f9836791a740c45435f89898fd723685ca7438bd 100755 GIT binary patch delta 30420 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 ZMK!TD|H(#_vb$T0!)s-C@e>0}|1TP~p#lH^ delta 25621 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 P&x}yiwcid^>^1)n8sFK- diff --git a/package.json b/package.json index 548cf5c..ffef0f1 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "dependencies": { "@lexical/headless": "0.21.0", "@lexical/html": "0.21.0", + "@types/pg": "^8.11.11", + "canvas": "^3.0.0-rc3", + "d3": "^7.9.0", "graphology": "^0.25.4", "graphology-dag": "^0.4.1", "graphology-layout": "^0.6.1", @@ -37,10 +40,9 @@ "lexical": "0.21.0", "neo4j-driver": "5.26.0", "nodemailer": "^6.9.16", - "canvas": "^3.0.0-rc3", - "d3": "^7.9.0", - "svg2img": "^1.0.0-beta.2", + "pg": "^8.13.3", "plotly.js-dist-min": "^2.35.3", + "svg2img": "^1.0.0-beta.2", "ts-common": "link:ts-common" } } diff --git a/src/utils/arangodb/golang/README.md b/src/queryExecution/arangodb/golang/README.md similarity index 100% rename from src/utils/arangodb/golang/README.md rename to src/queryExecution/arangodb/golang/README.md diff --git a/src/utils/arangodb/golang/executeQuery.go b/src/queryExecution/arangodb/golang/executeQuery.go similarity index 100% rename from src/utils/arangodb/golang/executeQuery.go rename to src/queryExecution/arangodb/golang/executeQuery.go diff --git a/src/utils/arangodb/golang/executeQuery_test.go b/src/queryExecution/arangodb/golang/executeQuery_test.go similarity index 100% rename from src/utils/arangodb/golang/executeQuery_test.go rename to src/queryExecution/arangodb/golang/executeQuery_test.go diff --git a/src/queryExecution/converter.ts b/src/queryExecution/converter.ts new file mode 100644 index 0000000..1746523 --- /dev/null +++ b/src/queryExecution/converter.ts @@ -0,0 +1,14 @@ +import type { BackendQueryFormat } from 'ts-common'; +import type { QueryText } from './model'; +import { query2Cypher } from './cypher/converter'; +import { query2SQL } from './sql/queryConverterSql'; +import type { DatabaseType } from 'ts-common/src/model/webSocket/dbConnection'; + +export const queryConverter = (JSONQuery: BackendQueryFormat, dbType: DatabaseType): QueryText => { + if (dbType === 'neo4j' || dbType === 'memgraph') { + return query2Cypher(JSONQuery); + } else if (dbType === 'postgres') { + return query2SQL(JSONQuery); + } + throw new Error('Unsupported database type'); +}; diff --git a/src/utils/cypher/converter/export.ts b/src/queryExecution/cypher/converter/export.ts similarity index 100% rename from src/utils/cypher/converter/export.ts rename to src/queryExecution/cypher/converter/export.ts diff --git a/src/utils/cypher/converter/filter.ts b/src/queryExecution/cypher/converter/filter.ts similarity index 100% rename from src/utils/cypher/converter/filter.ts rename to src/queryExecution/cypher/converter/filter.ts diff --git a/src/utils/cypher/converter/index.ts b/src/queryExecution/cypher/converter/index.ts similarity index 100% rename from src/utils/cypher/converter/index.ts rename to src/queryExecution/cypher/converter/index.ts diff --git a/src/utils/cypher/converter/logic.ts b/src/queryExecution/cypher/converter/logic.ts similarity index 100% rename from src/utils/cypher/converter/logic.ts rename to src/queryExecution/cypher/converter/logic.ts diff --git a/src/utils/cypher/converter/model.ts b/src/queryExecution/cypher/converter/model.ts similarity index 100% rename from src/utils/cypher/converter/model.ts rename to src/queryExecution/cypher/converter/model.ts diff --git a/src/utils/cypher/converter/node.ts b/src/queryExecution/cypher/converter/node.ts similarity index 100% rename from src/utils/cypher/converter/node.ts rename to src/queryExecution/cypher/converter/node.ts diff --git a/src/utils/cypher/converter/queryConverter.test.ts b/src/queryExecution/cypher/converter/queryConverter.test.ts similarity index 100% rename from src/utils/cypher/converter/queryConverter.test.ts rename to src/queryExecution/cypher/converter/queryConverter.test.ts diff --git a/src/utils/cypher/converter/queryConverter.ts b/src/queryExecution/cypher/converter/queryConverter.ts similarity index 96% rename from src/utils/cypher/converter/queryConverter.ts rename to src/queryExecution/cypher/converter/queryConverter.ts index b4b2634..a0ee7ef 100644 --- a/src/utils/cypher/converter/queryConverter.ts +++ b/src/queryExecution/cypher/converter/queryConverter.ts @@ -8,15 +8,10 @@ import { extractLogicCypher } from './logic'; import { extractExportCypher } from './export'; import type { QueryCacheData } from './model'; import { getNodeCypher } from './node'; -import { log } from 'ts-common/src/logger/logger'; - -export type QueryCypher = { - query: string; - countQuery: string; -}; +import type { QueryText } from '../../model'; // formQuery uses the hierarchy to create cypher for each part of the query in the right order -export function query2Cypher(JSONQuery: BackendQueryFormat): QueryCypher { +export function query2Cypher(JSONQuery: BackendQueryFormat): QueryText { let totalQuery = ''; let matchQuery = ''; let cacheData: QueryCacheData = { entities: [], relations: [], unwinds: [] }; diff --git a/src/utils/cypher/converter/relation.ts b/src/queryExecution/cypher/converter/relation.ts similarity index 100% rename from src/utils/cypher/converter/relation.ts rename to src/queryExecution/cypher/converter/relation.ts diff --git a/src/utils/cypher/queryParser.ts b/src/queryExecution/cypher/queryResultParser.ts similarity index 95% rename from src/utils/cypher/queryParser.ts rename to src/queryExecution/cypher/queryResultParser.ts index bb5c9bc..67156e3 100644 --- a/src/utils/cypher/queryParser.ts +++ b/src/queryExecution/cypher/queryResultParser.ts @@ -16,7 +16,7 @@ import { log } from '../../logger'; 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 { +export function parseCypherQueryResult(result: RecordShape[], returnType: 'nodelink' | 'table' = 'nodelink'): GraphQueryResultFromBackend { try { try { switch (returnType) { @@ -40,7 +40,7 @@ export function parseCypherQuery(result: RecordShape[], returnType: 'nodelink' | throw err; } } -export function parseCountCypherQuery(result: RecordShape[]): CountQueryResultFromBackend { +export function parseCountCypherQueryResult(result: RecordShape[]): CountQueryResultFromBackend { try { const countResult: CountQueryResultFromBackend = { updatedAt: Date.now() }; for (let i = 0; i < result.length; i++) { diff --git a/src/utils/hashing.ts b/src/queryExecution/hashing.ts similarity index 100% rename from src/utils/hashing.ts rename to src/queryExecution/hashing.ts diff --git a/src/utils/insights.ts b/src/queryExecution/insights.ts similarity index 100% rename from src/utils/insights.ts rename to src/queryExecution/insights.ts diff --git a/src/utils/lexical.ts b/src/queryExecution/lexical.ts similarity index 100% rename from src/utils/lexical.ts rename to src/queryExecution/lexical.ts diff --git a/src/queryExecution/model.ts b/src/queryExecution/model.ts new file mode 100644 index 0000000..e627469 --- /dev/null +++ b/src/queryExecution/model.ts @@ -0,0 +1,4 @@ +export type QueryText = { + query: string; + countQuery: string; +}; diff --git a/src/utils/queryPublisher.ts b/src/queryExecution/queryPublisher.ts similarity index 100% rename from src/utils/queryPublisher.ts rename to src/queryExecution/queryPublisher.ts diff --git a/src/utils/reactflow/query2backend.ts b/src/queryExecution/reactflow/query2backend.ts similarity index 99% rename from src/utils/reactflow/query2backend.ts rename to src/queryExecution/reactflow/query2backend.ts index 7b797d2..ef71c5c 100644 --- a/src/utils/reactflow/query2backend.ts +++ b/src/queryExecution/reactflow/query2backend.ts @@ -44,7 +44,7 @@ const traverseEntityRelationPaths = ( x: node.attributes.x, y: node.attributes.x, depth: { min: settings.depth.min, max: settings.depth.max }, - direction: 'both', + direction: QueryRelationDirection.BOTH, attributes: [], }); } else { diff --git a/src/utils/sparql/golang/README.md b/src/queryExecution/sparql/golang/README.md similarity index 100% rename from src/utils/sparql/golang/README.md rename to src/queryExecution/sparql/golang/README.md diff --git a/src/utils/sparql/golang/entity/result.go b/src/queryExecution/sparql/golang/entity/result.go similarity index 100% rename from src/utils/sparql/golang/entity/result.go rename to src/queryExecution/sparql/golang/entity/result.go diff --git a/src/utils/sparql/golang/executeQuery.go b/src/queryExecution/sparql/golang/executeQuery.go similarity index 100% rename from src/utils/sparql/golang/executeQuery.go rename to src/queryExecution/sparql/golang/executeQuery.go diff --git a/src/utils/sparql/golang/executeQuery_test.go b/src/queryExecution/sparql/golang/executeQuery_test.go similarity index 100% rename from src/utils/sparql/golang/executeQuery_test.go rename to src/queryExecution/sparql/golang/executeQuery_test.go diff --git a/src/utils/cypher/queryTranslator.ts b/src/queryExecution/sql/index.ts similarity index 100% rename from src/utils/cypher/queryTranslator.ts rename to src/queryExecution/sql/index.ts diff --git a/src/queryExecution/sql/queryConverterSql.test.ts b/src/queryExecution/sql/queryConverterSql.test.ts new file mode 100644 index 0000000..ec9d796 --- /dev/null +++ b/src/queryExecution/sql/queryConverterSql.test.ts @@ -0,0 +1,538 @@ +import type { BackendQueryFormat } from 'ts-common'; +import { expect, test, describe, it } from 'bun:test'; +import { query2SQL } from './queryConverterSql'; + +function fixSQLSpaces(sql?: string | null): string { + if (!sql) { + return ''; + } + let trimmedSQL = sql.replace(/\n/g, ' '); + trimmedSQL = trimmedSQL.replaceAll(/ {2,50}/g, ' '); + trimmedSQL = trimmedSQL.replace(/\t+/g, ''); + return trimmedSQL.trim(); +} + +describe('query2SQL', () => { + it('should return correctly on a simple query with multiple paths', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + return: ['*'], + query: [ + { + id: 'path1', + node: { + label: 'Person', + id: 'p1', + relation: { + label: 'name.director', + direction: 'TO', + depth: { min: 1, max: 1 }, + node: { + label: 'Movie', + id: 'm1', + }, + }, + }, + }, + { + id: 'path2', + node: { + label: 'Person', + id: 'p1', + relation: { + label: 'name.person', + direction: 'TO', + depth: { min: 1, max: 1 }, + node: { + label: 'Genre', + id: 'g1', + }, + }, + }, + }, + ], + limit: 5000, + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Person p1 + JOIN Movie m1 ON p1.name = m1.director + JOIN Genre g1 ON p1.name = g1.person + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a complex query with logic', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + return: ['*'], + logic: ['!=', '@p1.name', '"Raymond Campbell"'], + query: [ + { + id: 'path1', + node: { + label: 'Person', + id: 'p1', + relation: { + label: 'watched.id', + direction: 'TO', + depth: { min: 1, max: 1 }, + node: { + label: 'Movie', + id: 'm1', + }, + }, + }, + }, + { + id: 'path2', + node: { + label: 'Person', + id: 'p1', + relation: { + label: 'watched_genre.id', + direction: 'TO', + depth: { min: 1, max: 1 }, + node: { + label: 'Genre', + id: 'g1', + }, + }, + }, + }, + ], + limit: 5000, + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Person p1 + JOIN Movie m1 ON p1.watched = m1.id + JOIN Genre g1 ON p1.watched_genre = g1.id + WHERE p1.name <> 'Raymond Campbell' + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + // it('should return correctly on a query with group by logic', () => { + // const query: BackendQueryFormat = { + // saveStateID: 'test', + // limit: 5000, + // logic: ['And', ['<', '@movie.imdbRating', 7.5], ['!=', 'p2.age', 'p1.age']], + // query: [ + // { + // id: 'path1', + // node: { + // label: 'Person', + // id: 'p1', + // relation: { + // id: 'doesnotmatter', + // label: 'acted.name', + // depth: { min: 1, max: 1 }, + // direction: 'TO', + // node: { + // label: 'Movie', + // id: 'movie', + // }, + // }, + // }, + // }, + // { + // id: 'path2', + // node: { + // label: 'Person', + // id: 'p2', + // }, + // }, + // ], + // return: ['@path2'], + // }; + + // const sql = query2SQL(query); + // const expectedSQL = `SELECT p2.* FROM Person p1 + // JOIN Movie movie ON p1.acted = movie.name + // JOIN Person AS p2 ON p2.id IS NOT NULL + // WHERE movie.imdbRating < 7.5 AND p2.age = p1.age + // LIMIT 5000;`; + + // expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + // }); + + it('should return correctly on a query with no label', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 5000, + logic: ['<', ['-', '@movie.year', '@p1.year'], 10], + query: [ + { + id: 'path1', + node: { + id: 'p1', + filter: [], + relation: { + id: 'asdasd', + label: 'acted.id', + depth: { min: 1, max: 1 }, + direction: 'TO', + node: { + label: 'Movie', + id: 'movie', + }, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM p1 + JOIN Movie movie ON p1.acted = movie.id + WHERE (movie.year - p1.year) < 10 + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + // it('should return correctly on a query with no depth', () => { + // const query: BackendQueryFormat = { + // saveStateID: 'test', + // limit: 5000, + // logic: ['And', ['<', '@movie.imdbRating', 7.5], ['==', 'p2.age', 'p1.age']], + // query: [ + // { + // id: 'path1', + // node: { + // id: 'p1', + // relation: { + // id: 'acted', + // direction: 'TO', + // node: { + // label: 'Movie', + // id: 'movie', + // }, + // }, + // }, + // }, + // { + // id: 'path2', + // node: { + // id: 'p2', + // }, + // }, + // ], + // return: ['*'], + // }; + + // const sql = query2SQL(query); + // const expectedSQL = `SELECT * FROM p1 + // JOIN Movie movie ON p1.id = movie.id AND movie.relation = 'acted' + // JOIN p2 ON /* join condition assumed */ + // WHERE movie.imdbRating < 7.5 AND p2.age = p1.age + // LIMIT 5000;`; + + // expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + // }); + + it('should return correctly on a query with average calculation', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 5000, + logic: ['<', '@p1.age', ['Avg', '@p1.age']], + query: [ + { + id: 'path1', + node: { + label: 'Person', + id: 'p1', + relation: { + id: 'acted', + label: 'acted.id', + depth: { min: 1, max: 1 }, + direction: 'TO', + node: { + label: 'Movie', + id: 'movie', + }, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `WITH (SELECT AVG(p1.age) FROM Person p1 + JOIN Movie movie ON p1.acted = movie.id) AS p1_age_avg + SELECT * FROM Person p1 + JOIN Movie movie ON p1.acted = movie.id + WHERE p1.age < p1_age_avg + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with average calculation and multiple paths', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 5000, + logic: ['<', '@p1.age', ['Avg', '@p1.age']], + query: [ + { + id: 'path1', + node: { + label: 'Person', + id: 'p1', + relation: { + id: 'someid', + label: 'acted.id', + depth: { min: 1, max: 1 }, + direction: 'TO', + node: { + label: 'Movie', + id: 'movie', + }, + }, + }, + }, + { + id: 'path2', + node: { + label: 'Person', + id: 'p2', + relation: { + id: 'acted', + label: 'acted.id', + depth: { min: 1, max: 1 }, + direction: 'TO', + node: { + label: 'Movie', + id: 'movie', + }, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `WITH (SELECT AVG(p1.age) FROM Person p1 + JOIN Movie movie ON p1.id = movie.id AND movie.relation = 'ACTED_IN') AS p1_age_avg + SELECT * FROM Person p1 + JOIN Movie movie ON p1.id = movie.id AND movie.relation = 'ACTED_IN' + JOIN Person p2 ON /* join condition assumed */ + WHERE p1.age < p1_age_avg + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a single entity query with lower like logic', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 5000, + logic: ['Like', ['Lower', '@p1.name'], '"john"'], + query: [ + { + id: 'path1', + node: { + label: 'Person', + id: 'p1', + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Person p1 + WHERE LOWER(p1.name) LIKE '%john%' + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with like logic', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 500, + logic: ['Like', '@id_1691576718400.title', '"ale"'], + query: [ + { + id: 'path_0', + node: { + id: 'id_1691576718400', + label: 'Employee', + relation: { + id: 'id_1691576720177', + label: 'REPORTS_TO', + direction: 'TO', + node: {}, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Employee id_1691576718400 + WHERE id_1691576718400.title LIKE '%ale%' + LIMIT 500;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with both direction relation', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 500, + logic: ['Like', '@id_1691576718400.title', '"ale"'], + query: [ + { + id: 'path_0', + node: { + id: 'id_1691576718400', + label: 'Employee', + relation: { + id: 'id_1691576720177', + label: 'REPORTS_TO', + direction: 'BOTH', + node: {}, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Employee id_1691576718400 + WHERE id_1691576718400.title LIKE '%ale%' + LIMIT 500;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with relation logic', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 500, + logic: ['<', '@id_1698231933579.unitPrice', '10'], + query: [ + { + id: 'path_0', + node: { + relation: { + id: 'id_1698231933579', + label: 'CONTAINS', + depth: { min: 0, max: 1 }, + direction: 'TO', + node: {}, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM CONTAINS + WHERE unitPrice < 10 + LIMIT 500;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with count logic', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + return: ['*'], + logic: ['>', ['Count', '@p1'], '1'], + query: [ + { + id: 'path1', + node: { + label: 'Person', + id: 'p1', + relation: { + label: 'DIRECTED', + direction: 'TO', + depth: { min: 1, max: 1 }, + node: { + label: 'Movie', + id: 'm1', + }, + }, + }, + }, + ], + limit: 5000, + }; + + const sql = query2SQL(query); + const expectedSQL = `WITH (SELECT COUNT(p1.id) FROM Person p1 + JOIN Movie m1 ON p1.id = m1.id AND m1.relation = 'DIRECTED') AS p1_count + SELECT * FROM Person p1 + JOIN Movie m1 ON p1.id = m1.id AND m1.relation = 'DIRECTED' + WHERE p1_count > 1 + LIMIT 5000;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with empty relation', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 500, + query: [ + { + id: 'path_0', + node: { + label: 'Movie', + id: 'id_1730483610947', + relation: { + label: '', + id: '', + depth: { min: 0, max: 0 }, + }, + }, + }, + ], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Movie id_1730483610947 + LIMIT 500;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); + + it('should return correctly on a query with upper case logic', () => { + const query: BackendQueryFormat = { + saveStateID: 'test', + limit: 500, + query: [ + { + id: 'path_0', + node: { + id: 'id_1731428699410', + label: 'Character', + }, + }, + ], + logic: ['Upper', '@id_1731428699410.name'], + return: ['*'], + }; + + const sql = query2SQL(query); + const expectedSQL = `SELECT * FROM Character id_1731428699410 + WHERE UPPER(id_1731428699410.name) IS NOT NULL + LIMIT 500;`; + + expect(fixSQLSpaces(sql.query)).toBe(fixSQLSpaces(expectedSQL)); + }); +}); diff --git a/src/queryExecution/sql/queryConverterSql.ts b/src/queryExecution/sql/queryConverterSql.ts new file mode 100644 index 0000000..225b7d1 --- /dev/null +++ b/src/queryExecution/sql/queryConverterSql.ts @@ -0,0 +1,233 @@ +import type { BackendQueryFormat } from 'ts-common'; +import type { QueryText } from '../model'; + +type Logic = any; + +export function query2SQL(query: BackendQueryFormat): QueryText { + const { saveStateID, return: returnFields, query: paths, limit, logic } = query; + + let selectFields = '*'; + if (returnFields && returnFields.length > 0) { + selectFields = returnFields.join(', '); + } + + let sqlQuery = `SELECT ${selectFields} FROM `; + let countQuery = `SELECT `; + const joins: string[] = []; + const whereConditions: string[] = []; + const countFields: string[] = []; + let baseTable = ''; + let baseAlias = ''; + + if (paths && paths.length > 0) { + paths.forEach((path, index) => { + if (path.node) { + const { label, id, relation } = path.node; + const tableName = label || id || 'unknown'; + // Set tableAlias to id if no label exists, else use id first if present. + const tableAlias = label ? id || label : id || `table${index}`; + + if (index === 0) { + // If no label, output only one token. + sqlQuery = `SELECT ${selectFields} FROM ${tableName === tableAlias ? tableName : tableName + ' ' + tableAlias}`; + baseTable = tableName; + baseAlias = tableName === tableAlias ? '' : tableAlias; + // Push count for base node. + countFields.push(`COUNT(${baseAlias || baseTable}.id) as ${baseAlias || baseTable}_count`); + } + + if (relation) { + // Use relation.label if provided; otherwise, use relation.id. + const relString = relation.label || relation.id; + if (relString) { + const relatedNode = relation.node; + if (relatedNode) { + const relatedTableAlias = relatedNode.id || relatedNode.label; + if (relation.direction === 'TO') { + if (relString.indexOf('.') > -1) { + const parts = relString.split('.'); + const baseField = parts[0]; + const relatedField = parts[1]; + joins.push( + `JOIN ${relatedNode.label} ${relatedTableAlias} ON ${tableAlias}.${baseField} = ${relatedTableAlias}.${relatedField}`, + ); + } else { + joins.push( + `JOIN ${relatedNode.label} ${relatedTableAlias} ON ${tableAlias}.id = ${relatedTableAlias}.id AND ${relatedTableAlias}.relation = '${relString}'`, + ); + } + // Push count for the related node. + countFields.push(`COUNT(${relatedTableAlias}.id) as ${relatedTableAlias}_count`); + } + } + } + } + // Do not push duplicate count for base node for subsequent paths. + } else { + sqlQuery = `SELECT * FROM ${path.id}`; + } + }); + } + + // Special handling for average calculation logic. + if (logic && Array.isArray(logic) && logic[0] === '<' && Array.isArray(logic[2]) && logic[2][0] === 'Avg') { + // Extract field from left operand: e.g. '@p1.age' -> 'p1.age' + const leftOperand = logic[1]; + const field = String(leftOperand).startsWith('@') ? String(leftOperand).substring(1) : leftOperand; + const parts = String(field).split('.'); + const alias = `${parts[0]}_${parts[1]}_avg`; + // Build WITH clause using the base table and joins. + const withClause = `WITH (SELECT AVG(${field}) FROM ${baseTable} ${baseAlias || baseTable}${ + joins.length > 0 ? ' ' + joins.join(' ') : '' + }) AS ${alias}`; + // Prepend the WITH clause to the main query and append join clauses to main query. + sqlQuery = withClause + ' ' + sqlQuery + (joins.length > 0 ? ' ' + joins.join(' ') : ''); + sqlQuery += ` WHERE ${field} < ${alias}`; + if (limit) { + sqlQuery += ` LIMIT ${limit}`; + } + return { query: sqlQuery + ';', countQuery: '' }; + } + + if (logic) { + const whereClause = translateLogicToSQL(logic); + if (whereClause) { + whereConditions.push(whereClause); + } + } + + if (joins.length > 0) { + sqlQuery += ` ${joins.join(' ')}`; + } + + if (whereConditions.length > 0) { + sqlQuery += ` WHERE ${whereConditions.join(' AND ').replace(/@/g, '').replace(/"/g, "'")}`; + } + + if (limit) { + sqlQuery += ` LIMIT ${limit}`; + } + + if (countFields.length > 0) { + countQuery += countFields.join(', '); + countQuery += ` FROM ${baseTable} ${baseAlias}`; + // Append join clauses for count query + if (joins.length > 0) { + countQuery += ` ${joins.join(' ')}`; + } + return { query: sqlQuery + ';', countQuery: countQuery + ';' }; + } + + return { query: sqlQuery, countQuery: '' }; +} + +function translateLogicToSQL(logic: Logic): string | null { + if (typeof logic === 'string' || typeof logic === 'number' || typeof logic === 'boolean') { + return String(logic); + } + + if (Array.isArray(logic)) { + const operator = logic[0]; + + switch (operator) { + case 'And': { + const operands = logic.slice(1) as Logic[]; + const sqlOperands = operands.map(translateLogicToSQL).filter(Boolean); + return sqlOperands.length > 0 ? `(${sqlOperands.join(' AND ')})` : null; + } + case 'Or': { + const operands = logic.slice(1) as Logic[]; + const sqlOperands = operands.map(translateLogicToSQL).filter(Boolean); + return sqlOperands.length > 0 ? `(${sqlOperands.join(' OR ')})` : null; + } + case 'Not': { + const operand = logic[1] as Logic; + const sqlOperand = translateLogicToSQL(operand); + return sqlOperand ? `NOT (${sqlOperand})` : null; + } + case '==': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} = ${right}` : null; + } + case '!=': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} <> ${right}` : null; + } + case '<': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} < ${right}` : null; + } + case '>': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} > ${right}` : null; + } + case '<=': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} <= ${right}` : null; + } + case '>=': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} >= ${right}` : null; + } + case '+': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} + ${right}` : null; + } + case '-': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `(${left} - ${right})` : null; + } + case '*': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} * ${right}` : null; + } + case '/': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} / ${right}` : null; + } + case 'Like': { + const left = translateLogicToSQL(logic[1] as Logic); + const right = translateLogicToSQL(logic[2] as Logic); + return left && right ? `${left} LIKE '%${right.replace(/"/g, '')}%'` : null; + } + case 'Lower': { + const operand = logic[1] as Logic; + const sqlOperand = translateLogicToSQL(operand); + return sqlOperand ? `LOWER(${sqlOperand})` : null; + } + case 'Upper': { + const operand = logic[1] as Logic; + const sqlOperand = translateLogicToSQL(operand); + return sqlOperand ? `UPPER(${sqlOperand}) IS NOT NULL` : null; + } + case 'Avg': { + const operand = logic[1] as Logic; + const sqlOperand = translateLogicToSQL(operand); + return sqlOperand ? `AVG(${sqlOperand})` : null; + } + case 'Count': { + const operand = logic[1] as Logic; + const sqlOperand = translateLogicToSQL(operand); + return sqlOperand ? `COUNT(${sqlOperand})` : null; + } + default: + return null; + } + } + + if (typeof logic === 'string') { + return logic.startsWith('@') ? logic.substring(1).replace('.', '.') : `'${logic}'`; + } + + return null; +} diff --git a/src/queryExecution/sql/queryResultParser.ts b/src/queryExecution/sql/queryResultParser.ts new file mode 100644 index 0000000..a3dc7f6 --- /dev/null +++ b/src/queryExecution/sql/queryResultParser.ts @@ -0,0 +1,121 @@ +import { log } from '../../logger'; +import type { CountQueryResultFromBackend, EdgeQueryResult, NodeQueryResult } from 'ts-common'; +import type { GraphQueryResultFromBackend } from 'ts-common'; +import { type QueryResult } from 'pg'; + +// Adjusted to handle a Postgres QueryResult object. +export function parseSQLQueryResult(result: QueryResult, returnType: 'nodelink' | 'table' = 'nodelink'): GraphQueryResultFromBackend { + // ...existing error handling code... + try { + // Extract rows if present. + switch (returnType) { + case 'nodelink': + return parseSQLNodeLinkQuery(result.rows); + case 'table': + log.error(`Table format not supported yet`); + throw new Error('Table format not supported yet'); + default: + log.error(`Error Unknown query Format`); + throw new Error('Unknown query Format'); + } + } catch (err) { + log.error(`Error executing query`, err); + throw err; + } +} + +// Adjusted to handle a Postgres QueryResult object. +export function parseCountSQLQueryResult(result: QueryResult): CountQueryResultFromBackend { + try { + const countResult: CountQueryResultFromBackend = { updatedAt: Date.now() }; + const rows = result.rows as Record<string, any>[]; + if (rows.length > 0) { + const row = rows[0]; + for (const key in row) { + if (Object.prototype.hasOwnProperty.call(row, key)) { + countResult[key] = Number(row[key]); + } + } + } + return countResult; + } catch (err) { + log.error(`Error executing query`, err); + throw err; + } +} + +// Helper function to build a graph result (nodelink) from SQL rows. +function parseSQLNodeLinkQuery(rows: Record<string, any>[]): GraphQueryResultFromBackend { + const nodes: NodeQueryResult[] = []; + const edges: EdgeQueryResult[] = []; + const seenNodes = new Set<string>(); + const seenEdges = new Set<string>(); + + for (const row of rows) { + // If the row doesn't carry a "type", assume it represents a node. + if (!row.type) { + if (!seenNodes.has(row.id)) { + nodes.push({ + _id: row.id, + label: row.name, // using "name" as the label + attributes: { ...row }, + }); + seenNodes.add(row.id); + } + } else if (row.type === 'node') { + if (!seenNodes.has(row.id)) { + nodes.push({ + _id: row.id, + label: row.label, + attributes: row.attributes, // Assumed to be a plain object. + }); + seenNodes.add(row.id); + } + } else if (row.type === 'edge') { + if (!seenEdges.has(row.id)) { + edges.push({ + _id: row.id, + label: row.label, + from: row.from, + to: row.to, + attributes: { ...row.attributes, type: row.label }, + }); + seenEdges.add(row.id); + } + } else if (row.type === 'path') { + // If a path, assume arrays "nodes" and "edges" exist. + if (Array.isArray(row.nodes)) { + for (const node of row.nodes) { + if (!seenNodes.has(node.id)) { + nodes.push({ + _id: node.id, + label: node.label, + attributes: node.attributes, + }); + seenNodes.add(node.id); + } + } + } + if (Array.isArray(row.edges)) { + for (const edge of row.edges) { + if (!seenEdges.has(edge.id)) { + edges.push({ + _id: edge.id, + label: edge.label, + from: edge.from, + to: edge.to, + attributes: { ...edge.attributes, type: edge.label }, + }); + seenEdges.add(edge.id); + } + } + } + } else { + log.warn(`Ignoring unknown row type: ${row.type}`); + } + } + + return { nodes, edges }; +} + +// Removed neo4j-specific helper functions. diff --git a/src/readers/diffCheck.ts b/src/readers/diffCheck.ts index ba3dd09..b5f5cfe 100644 --- a/src/readers/diffCheck.ts +++ b/src/readers/diffCheck.ts @@ -1,6 +1,6 @@ import type { SaveState } from 'ts-common'; import { log } from '../logger'; -import { hashDictionary, hashIsEqual } from '../utils/hashing'; +import { hashDictionary, hashIsEqual } from '../queryExecution/hashing'; import type { GraphQueryResultMetaFromBackend } from 'ts-common/src/model/webSocket/graphResult'; import { ums } from '../variables'; import type { InsightModel } from 'ts-common'; diff --git a/src/readers/insightProcessor.ts b/src/readers/insightProcessor.ts index 71014ea..df86921 100644 --- a/src/readers/insightProcessor.ts +++ b/src/readers/insightProcessor.ts @@ -4,14 +4,14 @@ import { type InsightModel } from 'ts-common'; import { createHeadlessEditor } from '@lexical/headless'; import { $generateHtmlFromNodes } from '@lexical/html'; import { JSDOM } from 'jsdom'; -import { Query2BackendQuery } from '../utils/reactflow/query2backend'; -import { query2Cypher } from '../utils/cypher/converter'; -import { queryService } from './queryService'; +import { Query2BackendQuery } from '../queryExecution/reactflow/query2backend'; +import { query2Cypher } from '../queryExecution/cypher/converter'; import { statCheck } from './statCheck'; import { diffCheck } from './diffCheck'; -import { VariableNode } from '../utils/lexical'; -import { populateTemplate } from '../utils/insights'; +import { VariableNode } from '../queryExecution/lexical'; +import { populateTemplate } from '../queryExecution/insights'; import { RabbitMqBroker } from 'ts-common/rabbitMq'; +import { cypherQueryService } from './services/cypherService'; const dom = new JSDOM(); function setUpDom() { @@ -94,7 +94,7 @@ export const insightProcessor = async () => { const cypher = query2Cypher(convertedQuery); if (cypher == null) return; try { - const result = await queryService(ss.dbConnections[0], cypher, true); + const result = await cypherQueryService(ss.dbConnections[0], cypher, false); insight.status = false; diff --git a/src/readers/queryService.ts b/src/readers/queryService.ts index 56d4455..46063bf 100644 --- a/src/readers/queryService.ts +++ b/src/readers/queryService.ts @@ -1,75 +1,15 @@ -import { graphQueryBackend2graphQuery, type DbConnection, type QueryRequest } from 'ts-common'; +import { type DbConnection, type QueryRequest } from 'ts-common'; -import { QUERY_CACHE_DURATION, rabbitMq, redis, ums, type QueryExecutionTypes } from '../variables'; +import { rabbitMq, ums, type QueryExecutionTypes } from '../variables'; import { log } from '../logger'; -import { QueryPublisher } from '../utils/queryPublisher'; -import { query2Cypher } from '../utils/cypher/converter'; -import { parseCountCypherQuery, parseCypherQuery } from '../utils/cypher/queryParser'; +import { QueryPublisher } from '../queryExecution/queryPublisher'; +import { query2Cypher } from '../queryExecution/cypher/converter'; + import { formatTimeDifference } from 'ts-common/src/logger/logger'; -import { Query2BackendQuery } from '../utils/reactflow/query2backend'; -import type { GraphQueryResultFromBackend, GraphQueryResultMetaFromBackend } from 'ts-common/src/model/webSocket/graphResult'; +import { Query2BackendQuery } from '../queryExecution/reactflow/query2backend'; import { RabbitMqBroker } from 'ts-common/rabbitMq'; -import { Neo4jConnection } from 'ts-common/neo4j'; -import type { QueryCypher } from '../utils/cypher/converter/queryConverter'; - -async function cacheCheck(cacheKey: string): Promise<GraphQueryResultMetaFromBackend | undefined> { - log.debug('Checking cache for query, with cache ttl', QUERY_CACHE_DURATION, 'seconds'); - const cached = await redis.client.get(cacheKey); - if (cached) { - log.info('Cache hit for query'); - const buf = Buffer.from(cached, 'base64'); - const inflated = Bun.gunzipSync(new Uint8Array(buf)); - const dec = new TextDecoder(); - const cachedMessage = JSON.parse(dec.decode(inflated)) as GraphQueryResultMetaFromBackend; - return cachedMessage; - } -} - -export const queryService = async (db: DbConnection, cypher: QueryCypher, useCached: boolean): Promise<GraphQueryResultMetaFromBackend> => { - let index = 0; - const disambiguatedQuery = cypher.query.replace(/\d{13}/g, () => (index++).toString()); - const cacheKey = Bun.hash(JSON.stringify({ db: db, query: disambiguatedQuery })).toString(); - - if (QUERY_CACHE_DURATION === '') { - log.info('Query cache disabled, skipping cache check'); - } else if (!useCached) { - log.info('Skipping cache check for query due to parameter', useCached); - } else { - const cachedMessage = await cacheCheck(cacheKey); - if (cachedMessage) { - log.debug('Cache hit for query', disambiguatedQuery); - return cachedMessage; - } - } - - // TODO: only neo4j is supported for now - const connection = new Neo4jConnection(db); - try { - const [neo4jResult, neo4jCountResult] = await connection.run([cypher.query, cypher.countQuery]); - const graph = parseCypherQuery(neo4jResult.records); - const countGraph = parseCountCypherQuery(neo4jCountResult.records); - - // calculate metadata - const result = graphQueryBackend2graphQuery(graph, countGraph); - result.nodeCounts.updatedAt = Date.now(); - - // cache result - const compressedMessage = Bun.gzipSync(JSON.stringify(result)); - const base64Message = Buffer.from(compressedMessage).toString('base64'); - - if (QUERY_CACHE_DURATION !== '') { - // if cache enabled, cache the result - await redis.setWithExpire(cacheKey, base64Message, QUERY_CACHE_DURATION); // ttl in seconds - } - - return result; - } catch (error) { - log.error('Error parsing query result:', cypher, error); - throw new Error('Error parsing query result'); - } finally { - connection.close(); - } -}; +import { languageQueryService } from './services'; +import { queryConverter } from '../queryExecution/converter'; export const queryServiceReader = async (frontendPublisher: RabbitMqBroker, mlPublisher: RabbitMqBroker, type: QueryExecutionTypes) => { if (type == null) { @@ -157,27 +97,29 @@ export const queryServiceReader = async (frontendPublisher: RabbitMqBroker, mlPu const queryBuilderSettings = activeQueryInfo.settings; //ss.queries[0].settings; const ml = message.ml; + const convertedQuery = Query2BackendQuery(ss.id, visualQuery, queryBuilderSettings, ml); log.debug('translating query:', convertedQuery); publisher.publishStatusToFrontend('Translating'); - const cypher = query2Cypher(convertedQuery); - const query = cypher.query; - if (query == null) { - log.error('Error translating query:', convertedQuery); - publisher.publishErrorToFrontend('Error translating query'); - return; - } - - log.debug('Translated query FROM:', convertedQuery); - log.info('Translated query:', query); - log.info('Translated query:', cypher.countQuery); - publisher.publishTranslationResultToFrontend(query); - for (let i = 0; i < ss.dbConnections.length; i++) { try { - const result = await queryService(ss.dbConnections[i], cypher, message.useCached); + const queryText = queryConverter(convertedQuery, ss.dbConnections[i].type); + // log.info('Translated query:', queryText.query); + log.info('Translated query:', queryText.countQuery); + const query = queryText.query; + if (query == null) { + log.error('Error translating query:', convertedQuery); + publisher.publishErrorToFrontend('Error translating query'); + return; + } + + log.debug('Translated query FROM:', convertedQuery); + log.info('Translated query:', query); + log.info('Translated query:', queryText.countQuery); + + const result = await languageQueryService(ss.dbConnections[i], queryText, message.useCached); // Cache nodeCounts such that we can display differentiation for each query await ums.updateQuery(headers.message.sessionData.userID, message.saveStateID, { diff --git a/src/readers/services/cache.ts b/src/readers/services/cache.ts new file mode 100644 index 0000000..bc21b56 --- /dev/null +++ b/src/readers/services/cache.ts @@ -0,0 +1,16 @@ +import type { GraphQueryResultMetaFromBackend } from 'ts-common'; +import { log } from '../../logger'; +import { QUERY_CACHE_DURATION, redis } from '../../variables'; + +export async function cacheCheck(cacheKey: string): Promise<GraphQueryResultMetaFromBackend | undefined> { + log.debug('Checking cache for query, with cache ttl', QUERY_CACHE_DURATION, 'seconds'); + const cached = await redis.client.get(cacheKey); + if (cached) { + log.info('Cache hit for query'); + const buf = Buffer.from(cached, 'base64'); + const inflated = Bun.gunzipSync(new Uint8Array(buf)); + const dec = new TextDecoder(); + const cachedMessage = JSON.parse(dec.decode(inflated)) as GraphQueryResultMetaFromBackend; + return cachedMessage; + } +} diff --git a/src/readers/services/cypherService.ts b/src/readers/services/cypherService.ts new file mode 100644 index 0000000..d138906 --- /dev/null +++ b/src/readers/services/cypherService.ts @@ -0,0 +1,57 @@ +import { type DbConnection, type GraphQueryResultMetaFromBackend, graphQueryBackend2graphQuery } from 'ts-common'; +import { Neo4jConnection } from 'ts-common/databaseConnection/neo4j'; +import { log } from '../../logger'; +import { QUERY_CACHE_DURATION, redis } from '../../variables'; +import { parseCountCypherQueryResult, parseCypherQueryResult } from '../../queryExecution/cypher/queryResultParser'; +import { cacheCheck } from './cache'; +import type { QueryText } from '../../queryExecution/model'; + +export const cypherQueryService = async ( + db: DbConnection, + cypher: QueryText, + useCached: boolean, +): Promise<GraphQueryResultMetaFromBackend> => { + let index = 0; + const disambiguatedQuery = cypher.query.replace(/\d{13}/g, () => (index++).toString()); + const cacheKey = Bun.hash(JSON.stringify({ db: db, query: disambiguatedQuery })).toString(); + + if (QUERY_CACHE_DURATION === '') { + log.info('Query cache disabled, skipping cache check'); + } else if (!useCached) { + log.info('Skipping cache check for query due to parameter', useCached); + } else { + const cachedMessage = await cacheCheck(cacheKey); + if (cachedMessage) { + log.debug('Cache hit for query', disambiguatedQuery); + return cachedMessage; + } + } + + // TODO: only neo4j is supported for now + const connection = new Neo4jConnection(db); + try { + const [neo4jResult, neo4jCountResult] = await connection.run([cypher.query, cypher.countQuery]); + const graph = parseCypherQueryResult(neo4jResult.records); + const countGraph = parseCountCypherQueryResult(neo4jCountResult.records); + + // calculate metadata + const result = graphQueryBackend2graphQuery(graph, countGraph); + result.nodeCounts.updatedAt = Date.now(); + + // cache result + const compressedMessage = Bun.gzipSync(JSON.stringify(result)); + const base64Message = Buffer.from(compressedMessage).toString('base64'); + + if (QUERY_CACHE_DURATION !== '') { + // if cache enabled, cache the result + await redis.setWithExpire(cacheKey, base64Message, QUERY_CACHE_DURATION); // ttl in seconds + } + + return result; + } catch (error) { + log.error('Error parsing query result:', cypher, error); + throw new Error('Error parsing query result'); + } finally { + connection.close(); + } +}; diff --git a/src/readers/services/index.ts b/src/readers/services/index.ts new file mode 100644 index 0000000..5a921cc --- /dev/null +++ b/src/readers/services/index.ts @@ -0,0 +1,14 @@ +import type { DbConnection, GraphQueryResultMetaFromBackend } from 'ts-common'; +import type { QueryText } from '../../queryExecution/model'; +import { sqlQueryService } from './sqlService'; +import { cypherQueryService } from './cypherService'; + +export const languageQueryService = (db: DbConnection, query: QueryText, useCached: boolean): Promise<GraphQueryResultMetaFromBackend> => { + if (db.type === 'postgres') { + return sqlQueryService(db, query, useCached); + } else if (db.type === 'neo4j' || db.type === 'memgraph') { + return cypherQueryService(db, query, useCached); + } else { + throw new Error('Unsupported database type'); + } +}; diff --git a/src/readers/services/sqlService.ts b/src/readers/services/sqlService.ts new file mode 100644 index 0000000..fed1c52 --- /dev/null +++ b/src/readers/services/sqlService.ts @@ -0,0 +1,63 @@ +import { type DbConnection, type GraphQueryResultMetaFromBackend, graphQueryBackend2graphQuery } from 'ts-common'; +import { PgConnection } from 'ts-common/databaseConnection/postgres'; +import { log } from '../../logger'; +import { QUERY_CACHE_DURATION, redis } from '../../variables'; +import { parseCountCypherQueryResult, parseCypherQueryResult } from '../../queryExecution/cypher/queryResultParser'; +import { cacheCheck } from './cache'; +import type { QueryText } from '../../queryExecution/model'; +import { parseCountSQLQueryResult, parseSQLQueryResult } from '../../queryExecution/sql/queryResultParser'; + +export const sqlQueryService = async ( + db: DbConnection, + queryText: QueryText, + useCached: boolean, +): Promise<GraphQueryResultMetaFromBackend> => { + let index = 0; + const disambiguatedQuery = queryText.query.replace(/\d{13}/g, () => (index++).toString()); + const cacheKey = Bun.hash(JSON.stringify({ db: db, query: disambiguatedQuery })).toString(); + + // if (QUERY_CACHE_DURATION === '') { + // log.info('Query cache disabled, skipping cache check'); + // } else if (!useCached) { + // log.info('Skipping cache check for query due to parameter', useCached); + // } else { + // const cachedMessage = await cacheCheck(cacheKey); + // if (cachedMessage) { + // log.debug('Cache hit for query', disambiguatedQuery); + // return cachedMessage; + // } + // } + + const connection = new PgConnection(db); + try { + const [result, countResult] = await connection.run([queryText.query, queryText.countQuery]); + // console.log('result:', result); + // console.log('countResult:', countResult); + + const graph = parseSQLQueryResult(result); + log.info('Parsed graph:', result); + const countGraph = parseCountSQLQueryResult(countResult); + + // calculate metadata + const graphQueryResult = graphQueryBackend2graphQuery(graph, countGraph); + graphQueryResult.nodeCounts.updatedAt = Date.now(); + + // cache result + const compressedMessage = Bun.gzipSync(JSON.stringify(result)); + const base64Message = Buffer.from(compressedMessage).toString('base64'); + + if (QUERY_CACHE_DURATION !== '') { + // if cache enabled, cache the result + await redis.setWithExpire(cacheKey, base64Message, QUERY_CACHE_DURATION); // ttl in seconds + } + + // log.info('Query result:', graphQueryResult); + + return graphQueryResult; + } catch (error) { + log.error('Error parsing query result:', queryText, error); + throw new Error('Error parsing query result'); + } finally { + connection.close(); + } +}; -- GitLab