From 21887e009ae9db981e29d7a9db88f3880dab1be6 Mon Sep 17 00:00:00 2001 From: alexeh Date: Mon, 24 Feb 2025 10:03:21 +0100 Subject: [PATCH] WIP --- .../impact-calculation.query.builder.ts | 19 +-- .../impact-calculation.repository.ts | 4 +- .../unit/impact/impact-query-builder.spec.ts | 113 ++++++++++++++++++ .../indicator-strategy-factory.ts} | 0 4 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 api/test/unit/impact/impact-query-builder.spec.ts rename api/test/unit/{impact-query-building/impact-query-building.spec.ts => impact/indicator-strategy-factory.ts} (100%) diff --git a/api/src/modules/impact/calculation/impact-calculation.query.builder.ts b/api/src/modules/impact/calculation/impact-calculation.query.builder.ts index cecf07b83..d1c0710cd 100644 --- a/api/src/modules/impact/calculation/impact-calculation.query.builder.ts +++ b/api/src/modules/impact/calculation/impact-calculation.query.builder.ts @@ -23,10 +23,12 @@ export class ImpactQueryBuilderV2 { * Mapping of parameter names to their respective placeholder numbers. * This single source of truth ensures consistency. */ - private paramMapping: Record = { - geoRegionId: 1, - materialId: 2, - adminRegionId: 3, + + // TODO: due to how the query is run right now, we need to inject the table and column names as strings, but this will potentially change in the future + private paramMapping: Record = { + $1: 'sourcing_location."geoRegionId"', + $2: 'sourcing_location."adminRegionId"', + $3: 'sourcing_location."materialId"', }; /** @@ -36,13 +38,12 @@ export class ImpactQueryBuilderV2 { * @param query - SQL fragment with tokens to be replaced. * @returns Updated query with parameter placeholders. */ - injectQueryParameters(query: string): string { + private injectQueryParameters(query: string): string { let updatedQuery = query; - for (const key in this.paramMapping) { - const token = `{{${key}}}`; + for (const placeholder in this.paramMapping) { updatedQuery = updatedQuery - .split(token) - .join(`$${this.paramMapping[key]}`); + .split(placeholder) + .join(this.paramMapping[placeholder]); } return updatedQuery; } diff --git a/api/src/modules/impact/calculation/impact-calculation.repository.ts b/api/src/modules/impact/calculation/impact-calculation.repository.ts index 8b90c6cab..ef070435c 100644 --- a/api/src/modules/impact/calculation/impact-calculation.repository.ts +++ b/api/src/modules/impact/calculation/impact-calculation.repository.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; import { EntityManager } from 'typeorm'; -import { ImpactQueryBuilder } from 'modules/impact/calculation/impact-calculation.query.builder'; +import { ImpactQueryBuilderV2 } from 'modules/impact/calculation/impact-calculation.query.builder'; import { IIndicatorCalculationStrategy } from 'modules/impact/calculation/strategies/indicator-calculation.strategy.interface'; // TODO: Following the plan to offload the impact calculation to a DB table and batch processing instead of @@ -13,7 +13,7 @@ export class ImpactCalculationRepository { constructor( @InjectEntityManager() private readonly entityManager: EntityManager, - private readonly impactQueryBuilder: ImpactQueryBuilder, + private readonly impactQueryBuilder: ImpactQueryBuilderV2, ) {} async calculateRawImpact( diff --git a/api/test/unit/impact/impact-query-builder.spec.ts b/api/test/unit/impact/impact-query-builder.spec.ts new file mode 100644 index 000000000..85a452f7a --- /dev/null +++ b/api/test/unit/impact/impact-query-builder.spec.ts @@ -0,0 +1,113 @@ +import { IndicatorStrategyFactory } from 'modules/impact/calculation/indicator.strategy.factory'; +import { + Indicator, + INDICATOR_NAME_CODES, +} from 'modules/indicators/indicator.entity'; +import { ImpactQueryBuilderV2 } from '../../../src/modules/impact/calculation/impact-calculation.query.builder'; + +describe('ImpactQueryBuilder integration using strategies from Strategy Factory', () => { + let strategyFactory: IndicatorStrategyFactory; + let queryBuilder: ImpactQueryBuilderV2; + + beforeEach(() => { + strategyFactory = new IndicatorStrategyFactory(); + queryBuilder = new ImpactQueryBuilderV2(); + }); + + test('should generate selects and queries based on indicator strategies', () => { + const activeIndicators = [ + { nameCode: INDICATOR_NAME_CODES.LF }, + { nameCode: INDICATOR_NAME_CODES.DF_SLUC }, + ] as Indicator[]; + + const strategies = strategyFactory.getStrategies( + activeIndicators.map((i: Indicator) => i.nameCode), + ); + + const impactQuery = queryBuilder.buildQuery(strategies); + expect(normalize(impactQuery)).toBe(normalize(expectedQuery)); + }); + test('should generate query fragment correctly for a single active indicator (LF)', () => { + const activeIndicators = [ + { nameCode: INDICATOR_NAME_CODES.LF }, + ] as Indicator[]; + const strategies = strategyFactory.getStrategies( + activeIndicators.map((i: Indicator) => i.nameCode), + ); + const impactQuery = queryBuilder.buildQuery(strategies); + expect(impactQuery).toContain('as "harvest"'); + expect(impactQuery).toContain('as "production"'); + expect(impactQuery).not.toContain('as "DF_SLUC"'); + }); + + test('should deduplicate query fragments when duplicate indicators are active', () => { + const activeIndicators = [ + { nameCode: INDICATOR_NAME_CODES.LF }, + { nameCode: INDICATOR_NAME_CODES.LF }, + { nameCode: INDICATOR_NAME_CODES.DF_SLUC }, + { nameCode: INDICATOR_NAME_CODES.DF_SLUC }, + ] as Indicator[]; + const strategies = strategyFactory.getStrategies( + activeIndicators.map((i: Indicator) => i.nameCode), + ); + const impactQuery = queryBuilder.buildQuery(strategies); + + const productionMatches = (impactQuery.match(/as "production"/g) || []) + .length; + const harvestMatches = (impactQuery.match(/as "harvest"/g) || []).length; + const dfSlucMatches = (impactQuery.match(/as "DF_SLUC"/g) || []).length; + + expect(productionMatches).toBe(1); + expect(harvestMatches).toBe(1); + expect(dfSlucMatches).toBe(1); + }); + + test('should inject keys correctly replacing placeholders', () => { + const activeIndicators = [ + { nameCode: INDICATOR_NAME_CODES.DF_SLUC }, + ] as Indicator[]; + const strategies = strategyFactory.getStrategies( + activeIndicators.map((i: Indicator) => i.nameCode), + ); + const impactQuery = queryBuilder.buildQuery(strategies); + + expect(impactQuery).toContain('sourcing_location."geoRegionId"'); + expect(impactQuery).toContain('sourcing_location."adminRegionId"'); + expect(impactQuery).toContain('sourcing_location."materialId"'); + + expect(impactQuery).not.toContain('$1'); + expect(impactQuery).not.toContain('$2'); + expect(impactQuery).not.toContain('$3'); + }); +}); + +// Helper function to normalize strings to assert equality +function normalize(str: string): string { + return str.trim().replace(/\s+/g, ' '); +} + +const expectedQuery = + 'SELECT DISTINCT ON (sr.id)\n' + + ' sr.id as "sourcingRecordId",\n' + + ' sr.tonnage,\n' + + ' sr.year,\n' + + ' slwithmaterialh3data.id as "sourcingLocationId",\n' + + ' slwithmaterialh3data."materialH3DataId",\n' + + ' LF, DF_SLUC\n' + + ' FROM sourcing_records sr\n' + + ' INNER JOIN (\n' + + ' SELECT\n' + + ' sourcing_location.id,\n' + + ' "scenarioInterventionId",\n' + + ' "interventionType",\n' + + ' mth."h3DataId" as "materialH3DataId",\n' + + ' sum_material_over_georegion(sourcing_location."geoRegionId", sourcing_location."adminRegionId", \'harvest\') as "harvest", sum_material_over_georegion(sourcing_location."geoRegionId", sourcing_location."adminRegionId", \'producer\') as "production", get_annual_commodity_weighted_impact_over_georegion(sourcing_location."geoRegionId", \'DF_SLUC\', sourcing_location."adminRegionId", \'producer\') as "DF_SLUC"\n' + + ' FROM sourcing_location\n' + + ' INNER JOIN material_to_h3 mth\n' + + ' ON mth."materialId" = sourcing_location."materialId"\n' + + ' WHERE "scenarioInterventionId" IS NULL\n' + + ' AND "interventionType" IS NULL\n' + + ' AND mth."type" = \'producer\'\n' + + ' ) as slwithmaterialh3data\n' + + ' ON sr."sourcingLocationId" = slwithmaterialh3data.id;\n' + + ' ;'; diff --git a/api/test/unit/impact-query-building/impact-query-building.spec.ts b/api/test/unit/impact/indicator-strategy-factory.ts similarity index 100% rename from api/test/unit/impact-query-building/impact-query-building.spec.ts rename to api/test/unit/impact/indicator-strategy-factory.ts