diff --git a/bun.lockb b/bun.lockb index 94886d7ec58aab76974728e32acbce8e5b53710b..60c32177a430fc1b9b24ea51dc61fd09c778caf1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 548cf5cda331f384e8c4f132516a91c44403dfac..618e5a6384f3c1e38bf1f17daf499bb0011f0e63 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "1.0.0", "scripts": { "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest", "dev": "bun run --watch --inspect=6498 src/index.ts", "start": "bun run --production src/index.ts", "lint": "eslint src/**/* --no-error-on-unmatched-pattern" @@ -20,7 +20,8 @@ "@typescript-eslint/eslint-plugin": "^8.19.1", "@typescript-eslint/parser": "^8.19.1", "eslint": "^9.17.0", - "typescript-eslint": "^8.19.1" + "typescript-eslint": "^8.19.1", + "vitest": "^3.0.8" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/src/readers/queryService.ts b/src/readers/queryService.ts index 4609e9363341d77ad898702ae7ee2ab4cb955347..765a995e01b8c4b37126cd917a62ad5f4de574ba 100644 --- a/src/readers/queryService.ts +++ b/src/readers/queryService.ts @@ -87,6 +87,8 @@ export const queryServiceReader = async (frontendPublisher: RabbitMqBroker, mlPu } log.info('Starting query reader for', type); + const publisher = new QueryPublisher(frontendPublisher, mlPublisher); + const queryServiceConsumer = await new RabbitMqBroker( rabbitMq, 'requests-exchange', diff --git a/src/utils/cypher/converter/queryConverter.test.ts b/src/tests/query/queryConverter.test.ts similarity index 98% rename from src/utils/cypher/converter/queryConverter.test.ts rename to src/tests/query/queryConverter.test.ts index 5227c6cd6ed1d867142f8bacd4718795b3702f23..f5e819d779bee110c7945c121fbc0e559481ee06 100644 --- a/src/utils/cypher/converter/queryConverter.test.ts +++ b/src/tests/query/queryConverter.test.ts @@ -1,6 +1,10 @@ -import { query2Cypher } from './queryConverter'; +import { query2Cypher } from '../../utils/cypher/converter/queryConverter'; import { StringFilterTypes, type BackendQueryFormat } from 'ts-common'; -import { expect, test, describe, it } from 'bun:test'; +import { expect, describe, it } from 'vitest'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); function fixCypherSpaces(cypher?: string | null): string { if (!cypher) { diff --git a/src/tests/query/queryTranslator/queryTranslator.test.ts b/src/tests/query/queryTranslator/queryTranslator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..85e7d47fbb1e5edf21f3856333ced1dba5aa9505 --- /dev/null +++ b/src/tests/query/queryTranslator/queryTranslator.test.ts @@ -0,0 +1,96 @@ +import { expect, describe, it } from 'vitest'; +import type { QueryMultiGraph } from 'ts-common/src/model/graphology'; +import { MLTypesEnum } from 'ts-common/src/model/query/machineLearningModel'; +import { type QueryBuilderSettings } from 'ts-common/src/model/query/queryBuilderModel'; +import { Query2BackendQuery } from './../../../utils/reactflow/query2backend'; +import type { MachineLearning } from 'ts-common/src/model/query/queryRequestModel'; +import { type BackendQueryFormat } from 'ts-common'; +import { createQueryMultiGraphFromData, settingsBase, ss_id } from './testData'; +import { visualQuery_1 } from './testData'; +import { expectedResult_1 } from './testData'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +describe('query2backend', () => { + it('should return correctly a node - 0', () => { + const nodesData = [ + { + id: 'Movie', + schemaKey: 'Movie', + type: 'entity', + width: 100, + height: 100, + x: 50, + y: 50, + name: 'Movie', + attributes: [ + { name: '(# Connection)', type: 'float' }, + { name: 'tagline', type: 'string' }, + { name: 'votes', type: 'int' }, + { name: 'title', type: 'string' }, + { name: 'released', type: 'int' }, + ], + }, + ]; + + const visualQuery: QueryMultiGraph = createQueryMultiGraphFromData(nodesData, []); + + const ml: MachineLearning[] = [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ]; + + const result = Query2BackendQuery(ss_id, visualQuery, settingsBase, ml); + const expectedResult: BackendQueryFormat = { + saveStateID: 'test', + query: [ + { + id: 'path_0', + node: { + label: 'Movie', + id: 'Movie', + relation: undefined, + }, + }, + ], + machineLearning: [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ], + limit: 500, + return: ['*'], + cached: false, + logic: undefined, + }; + expect(result).toEqual(expectedResult); + }); + it('should return correctly on a simple query with multiple paths - 1', () => { + const ml: MachineLearning[] = [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ]; + + const result = Query2BackendQuery(ss_id, visualQuery_1, settingsBase, ml); + + expect(result).toEqual(expectedResult_1); + }); + /* + it('should return correctly on a complex query with logic', () => {}); + it('should return correctly on a query with group by logic', () => {}); + it('should return correctly on a query with no label', () => {}); + it('should return correctly on a query with no depth', () => {}); + it('should return correctly on a query with average calculation', () => {}); + it('should return correctly on a query with average calculation and multiple paths', () => {}); + it('should return correctly on a single entity query with lower like logic', () => {}); + it('should return correctly on a query with like logic', () => {}); + it('should return correctly on a query with both direction relation', () => {}); +*/ +}); diff --git a/src/tests/query/queryTranslator/testData.ts b/src/tests/query/queryTranslator/testData.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a3154ec2179a1f5a4c584ea54b359355228caf9 --- /dev/null +++ b/src/tests/query/queryTranslator/testData.ts @@ -0,0 +1,541 @@ +import type { QueryMultiGraph, QueryGraphNodes, QueryGraphEdges } from 'ts-common/src/model/graphology'; +import type { SerializedNode, SerializedEdge } from 'graphology-types'; +import { Handles, QueryElementTypes } from 'ts-common/src/model/reactflow'; +import { type BackendQueryFormat } from 'ts-common'; +import { MLTypesEnum } from 'ts-common/src/model/query/machineLearningModel'; +import { type QueryBuilderSettings } from 'ts-common/src/model/query/queryBuilderModel'; + +export function createQueryMultiGraphFromData(nodesData: any[], edgesData: any[]): QueryMultiGraph { + const nodes: SerializedNode<QueryGraphNodes>[] = nodesData.map(node => ({ + key: node.id, + attributes: { + id: node.id, + name: node.name, + schemaKey: node.schemaKey, + type: node.type, + width: node.width, + height: node.height, + x: node.x, + y: node.y, + attributes: node.attributes.map((attribute: any) => ({ + handleData: { + nodeId: node.id, + nodeName: node.name, + nodeType: node.type, + handleType: 'entityAttributeHandle' as Handles, // check if different reactflow Handles + attributeName: attribute.name, + attributeType: attribute.type, + }, + })), + leftRelationHandleId: { + nodeId: node.id, + nodeName: node.name, + nodeType: node.type, + handleType: 'entityLeftHandle' as Handles, + }, + rightRelationHandleId: { + nodeId: node.id, + nodeName: node.name, + nodeType: node.type, + handleType: 'entityRightHandle' as Handles, + }, + selected: node.selected || false, + }, + })); + + const edges: SerializedEdge<QueryGraphEdges>[] = edgesData.map(edge => ({ + source: edge.sourceNodeId, + target: edge.targetNodeId, + type: edge.type, + sourceHandleData: { + nodeId: edge.sourceNodeId, + nodeName: edge.sourceNodeName, + nodeType: edge.sourceNodeType, + handleType: edge.sourceHandleType, + attributeName: edge.sourceAttributeName, + attributeType: edge.sourceAttributeType, + }, + targetHandleData: { + nodeId: edge.targetNodeId, + nodeName: edge.targetNodeName, + nodeType: edge.targetNodeType, + handleType: edge.targetHandleType, + attributeName: edge.targetAttributeName, + attributeType: edge.targetAttributeType, + }, + })); + + return { + nodes: nodes, + edges: edges, + options: { + type: 'mixed', + multi: true, + allowSelfLoops: false, + }, + attributes: {}, + }; +} + +export const ss_id: string = 'test'; +export const settingsBase: QueryBuilderSettings = { + depth: { + max: 1, + min: 1, + }, + limit: 500, + layout: 'manual', + unionTypes: {}, + autocompleteRelation: true, +}; + +export const visualQuery_1: QueryMultiGraph = { + edges: [ + { + key: 'geid_183_0', + source: 'id_1741246511422', + target: 'id_1741246512287', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + attributeName: '', + }, + }, + }, + { + key: 'geid_183_1', + source: 'id_1741246512287', + target: 'id_1741246511585', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + attributeName: '', + }, + }, + }, + { + key: 'geid_262_0', + source: 'id_1741246511422', + target: 'id_1741246625352', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + attributeName: '', + }, + }, + }, + { + key: 'geid_319_0', + source: 'id_1741246625352', + target: 'id_1741246630119', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + attributeName: '', + }, + }, + }, + ], + nodes: [ + { + key: 'id_1741246512287', + attributes: { + x: 180, + y: 90, + id: 'id_1741246512287', + name: 'WROTE', + type: QueryElementTypes.Relation, + depth: { + max: 1, + min: 1, + }, + width: 86.15010000000001, + height: 20, + schemaKey: 'WROTE_PersonMovie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + ], + collection: 'WROTE', + leftEntityHandleId: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + }, + rightEntityHandleId: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + }, + }, + }, + { + key: 'id_1741246511422', + attributes: { + x: 0, + y: 90, + id: 'id_1741246511422', + name: 'Person', + type: QueryElementTypes.Entity, + width: 78.2166, + height: 20, + schemaKey: 'Person', + attributes: [ + { + handleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + { + handleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'name', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'born', + attributeType: 'int', + }, + }, + ], + leftRelationHandleId: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + }, + rightRelationHandleId: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + }, + }, + }, + { + key: 'id_1741246511585', + attributes: { + x: 430, + y: 90, + id: 'id_1741246511585', + name: 'Movie', + type: QueryElementTypes.Entity, + width: 72.1999, + height: 20, + schemaKey: 'Movie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'tagline', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'votes', + attributeType: 'int', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'title', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'released', + attributeType: 'int', + }, + }, + ], + leftRelationHandleId: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + }, + rightRelationHandleId: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + }, + }, + }, + { + key: 'id_1741246625352', + attributes: { + x: 180, + y: 170, + id: 'id_1741246625352', + name: 'PRODUCED', + type: QueryElementTypes.Relation, + depth: { + max: 1, + min: 1, + }, + width: 104.2002, + height: 20, + schemaKey: 'PRODUCED_PersonMovie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + ], + collection: 'PRODUCED', + leftEntityHandleId: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + }, + rightEntityHandleId: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + }, + }, + }, + { + key: 'id_1741246630119', + attributes: { + x: 390, + y: 200, + id: 'id_1741246630119', + name: 'Movie', + type: QueryElementTypes.Entity, + width: 72.1999, + height: 20, + schemaKey: 'Movie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'tagline', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'votes', + attributeType: 'int', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'title', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'released', + attributeType: 'int', + }, + }, + ], + leftRelationHandleId: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + }, + rightRelationHandleId: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + }, + }, + }, + ], + options: { + type: 'mixed', + multi: true, + allowSelfLoops: true, + }, + attributes: {}, +}; +export const expectedResult_1: BackendQueryFormat = { + saveStateID: 'test', + return: ['*'], + query: [ + { + id: 'path_0', + node: { + id: 'id_1741246511422', + label: 'Person', + relation: { + id: 'id_1741246512287', + label: 'WROTE', + depth: { + max: 1, + min: 1, + }, + direction: 'BOTH', + node: { + id: 'id_1741246511585', + label: 'Movie', + }, + }, + }, + }, + { + id: 'path_1', + node: { + id: 'id_1741246511422', + label: 'Person', + relation: { + id: 'id_1741246625352', + label: 'PRODUCED', + depth: { + max: 1, + min: 1, + }, + direction: 'BOTH', + node: { + id: 'id_1741246630119', + label: 'Movie', + }, + }, + }, + }, + ], + machineLearning: [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ], + limit: 500, + cached: false, + logic: undefined, +}; diff --git a/src/utils/queryPublisher.ts b/src/utils/queryPublisher.ts index d10bea719a1387114ca8c66ff08a5f42bc2b0930..ca17e2f169c136a5f7f73df401a68fe086749eb0 100644 --- a/src/utils/queryPublisher.ts +++ b/src/utils/queryPublisher.ts @@ -6,19 +6,35 @@ import type { RabbitMqBroker } from 'ts-common/rabbitMq'; export class QueryPublisher { private frontendPublisher: RabbitMqBroker; private mlPublisher: RabbitMqBroker; - private routingKey: string; - private headers: BackendMessageHeader; - private queryID: number; + private routingKey?: string; + private headers?: BackendMessageHeader; + private queryID?: string; - constructor(frontendPublisher: RabbitMqBroker, mlPublisher: RabbitMqBroker, headers: BackendMessageHeader, queryID: number) { + constructor(frontendPublisher: RabbitMqBroker, mlPublisher: RabbitMqBroker) { this.frontendPublisher = frontendPublisher; this.mlPublisher = mlPublisher; + } + + withHeaders(headers?: BackendMessageHeader) { this.headers = headers; - this.routingKey = headers.routingKey; + return this; + } + + withRoutingKey(routingKey?: string) { + this.routingKey = routingKey; + return this; + } + + withQueryID(queryID?: string) { this.queryID = queryID; + return this; } publishStatusToFrontend(status: string) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusUpdate, @@ -32,6 +48,10 @@ export class QueryPublisher { } publishErrorToFrontend(reason: string) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusError, @@ -45,13 +65,17 @@ export class QueryPublisher { } publishTranslationResultToFrontend(query: string) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusTranslationResult, callID: this.headers.callID, value: { result: query, - queryID: this.queryID, + queryID: this.headers.callID, }, status: 'success', }, @@ -61,6 +85,10 @@ export class QueryPublisher { } publishResultToFrontend(result: GraphQueryResultMetaFromBackend) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusResult, @@ -70,7 +98,7 @@ export class QueryPublisher { type: 'nodelink', payload: result, }, - queryID: this.queryID, + queryID: this.headers.callID, }, status: 'success', }, @@ -80,6 +108,10 @@ export class QueryPublisher { } publishMachineLearningRequest(result: GraphQueryResultFromBackend, mlAttributes: MachineLearning, headers: BackendMessageHeader) { + if (!this.headers || !this.routingKey) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + // FIXME: Change ML to use the same message format that the frontend uses const toMlResult = { nodes: result.nodes.map(node => ({ ...node, id: node._id })),