perf(analytics): collapse 6x POI COUNT queries into single GROUP BY (GOO-222)

Replace six sequential prisma.pOI.count() calls in countPOIs() with a
single raw SQL GROUP BY query, cutting DB round-trips from 6 to 1 per
neighborhood score calculation.

- Replace Promise.all + pOI.count loop with prisma.$queryRaw GROUP BY type
- Aggregate per-type counts into category totals client-side via CATEGORY_POI_TYPES map
- Zero-fill missing types preserves existing response shape and callers unchanged
- Update unit tests: mock $queryRaw instead of pOI.count; assert exactly
  1 DB call per calculateAndSave invocation (new assertion per acceptance criteria)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:13:03 +07:00
parent fa3ba88f40
commit aed173adca
2 changed files with 74 additions and 53 deletions

View File

@@ -4,11 +4,16 @@ import {
PrismaNeighborhoodScoreService, PrismaNeighborhoodScoreService,
} from '../services/neighborhood-score.service'; } from '../services/neighborhood-score.service';
// Helper: build the flat $queryRaw row list that countPOIs expects.
function makePoiRows(counts: Record<string, number>) {
return Object.entries(counts).map(([type, n]) => ({ type, count: BigInt(n) }));
}
describe('NeighborhoodScoreServiceImpl', () => { describe('NeighborhoodScoreServiceImpl', () => {
let service: NeighborhoodScoreServiceImpl; let service: NeighborhoodScoreServiceImpl;
let mockPrisma: { let mockPrisma: {
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> }; neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
pOI: { count: ReturnType<typeof vi.fn> }; $queryRaw: ReturnType<typeof vi.fn>;
}; };
let mockLogger: { log: ReturnType<typeof vi.fn> }; let mockLogger: { log: ReturnType<typeof vi.fn> };
@@ -18,7 +23,7 @@ describe('NeighborhoodScoreServiceImpl', () => {
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
pOI: { count: vi.fn() }, $queryRaw: vi.fn(),
}; };
mockLogger = { log: vi.fn() }; mockLogger = { log: vi.fn() };
@@ -60,44 +65,45 @@ describe('NeighborhoodScoreServiceImpl', () => {
}); });
describe('calculateAndSave', () => { describe('calculateAndSave', () => {
it('calculates scores from POI counts and upserts', async () => { it('issues exactly one DB query and calculates scores correctly', async () => {
// Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%), mockPrisma.$queryRaw.mockResolvedValue(
// shopping=5 (50%), greenery=3 (50%), safety=2 (50%) makePoiRows({
const poiCountsByCategory = [15, 4, 6, 5, 3, 2]; SCHOOL: 10, UNIVERSITY: 5,
let callIndex = 0; HOSPITAL: 2, CLINIC: 2,
mockPrisma.pOI.count.mockImplementation(() => { METRO_STATION: 3, BUS_STOP: 3,
return Promise.resolve(poiCountsByCategory[callIndex++]!); MALL: 2, MARKET: 2, SUPERMARKET: 1,
}); PARK: 3,
POLICE_STATION: 1, FIRE_STATION: 1,
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { }),
return Promise.resolve(create); );
}); mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
// education: 15/15 * 10 = 10 → 10 * 20/10 = 20
// healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10
// transport: 6/12 * 10 = 5 → 5 * 20/10 = 10
// shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5
// greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5
// safety: 2/4 * 10 = 5 → 5 * 10/10 = 5
// total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60
expect(result.educationScore).toBe(10); expect(result.educationScore).toBe(10);
expect(result.healthcareScore).toBe(5); expect(result.healthcareScore).toBe(5);
expect(result.totalScore).toBe(60); expect(result.totalScore).toBe(60);
// Assert single DB round-trip for all 6 categories
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1); expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
}); });
it('caps category scores at 10', async () => { it('caps category scores at 10', async () => {
// All categories have way more POIs than max mockPrisma.$queryRaw.mockResolvedValue(
mockPrisma.pOI.count.mockResolvedValue(100); makePoiRows({
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { SCHOOL: 100, UNIVERSITY: 100, HOSPITAL: 100, CLINIC: 100,
return Promise.resolve(create); METRO_STATION: 100, BUS_STOP: 100, MALL: 100, MARKET: 100,
}); SUPERMARKET: 100, PARK: 100, POLICE_STATION: 100, FIRE_STATION: 100,
}),
);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
// All scores capped at 10 → total = sum of weights = 100
expect(result.educationScore).toBe(10); expect(result.educationScore).toBe(10);
expect(result.healthcareScore).toBe(10); expect(result.healthcareScore).toBe(10);
expect(result.transportScore).toBe(10); expect(result.transportScore).toBe(10);
@@ -105,25 +111,27 @@ describe('NeighborhoodScoreServiceImpl', () => {
expect(result.greeneryScore).toBe(10); expect(result.greeneryScore).toBe(10);
expect(result.safetyScore).toBe(10); expect(result.safetyScore).toBe(10);
expect(result.totalScore).toBe(100); expect(result.totalScore).toBe(100);
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
}); });
it('returns 0 scores when no POIs exist', async () => { it('returns 0 scores when no POIs exist', async () => {
mockPrisma.pOI.count.mockResolvedValue(0); mockPrisma.$queryRaw.mockResolvedValue([]);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
return Promise.resolve(create); Promise.resolve(create),
}); );
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
expect(result.educationScore).toBe(0); expect(result.educationScore).toBe(0);
expect(result.totalScore).toBe(0); expect(result.totalScore).toBe(0);
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
}); });
it('logs the calculated score', async () => { it('logs the calculated score', async () => {
mockPrisma.pOI.count.mockResolvedValue(5); mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 }));
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
return Promise.resolve(create); Promise.resolve(create),
}); );
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
@@ -140,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => {
let prismaFallback: PrismaNeighborhoodScoreService; let prismaFallback: PrismaNeighborhoodScoreService;
let mockPrisma: { let mockPrisma: {
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> }; neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
pOI: { count: ReturnType<typeof vi.fn> }; $queryRaw: ReturnType<typeof vi.fn>;
}; };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> }; let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> }; let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
@@ -148,7 +156,7 @@ describe('HttpNeighborhoodScoreService', () => {
beforeEach(() => { beforeEach(() => {
mockPrisma = { mockPrisma = {
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() }, neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
pOI: { count: vi.fn() }, $queryRaw: vi.fn(),
}; };
mockLogger = { log: vi.fn(), warn: vi.fn() }; mockLogger = { log: vi.fn(), warn: vi.fn() };
mockAiClient = { scoreNeighborhood: vi.fn() }; mockAiClient = { scoreNeighborhood: vi.fn() };
@@ -165,7 +173,7 @@ describe('HttpNeighborhoodScoreService', () => {
}); });
it('persists AI service response when scoreNeighborhood succeeds', async () => { it('persists AI service response when scoreNeighborhood succeeds', async () => {
mockPrisma.pOI.count.mockResolvedValue(6); mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 }));
mockAiClient.scoreNeighborhood.mockResolvedValue({ mockAiClient.scoreNeighborhood.mockResolvedValue({
district: 'Quận 1', district: 'Quận 1',
city: 'Hồ Chí Minh', city: 'Hồ Chí Minh',
@@ -179,7 +187,9 @@ describe('HttpNeighborhoodScoreService', () => {
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 }, poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
algorithm_version: 'neighborhood-heuristic-v1', algorithm_version: 'neighborhood-heuristic-v1',
}); });
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create)); mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh'); const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
@@ -187,12 +197,15 @@ describe('HttpNeighborhoodScoreService', () => {
expect(result.totalScore).toBe(71.2); expect(result.totalScore).toBe(71.2);
expect(result.educationScore).toBe(8.5); expect(result.educationScore).toBe(8.5);
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce(); expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
}); });
it('falls back to prisma scoring when AI service throws', async () => { it('falls back to prisma scoring when AI service throws', async () => {
mockPrisma.pOI.count.mockResolvedValue(0); mockPrisma.$queryRaw.mockResolvedValue([]);
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down')); mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create)); mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh'); const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');

View File

@@ -143,18 +143,26 @@ async function countPOIs(
district: string, district: string,
city: string, city: string,
): Promise<AiNeighborhoodPOICounts> { ): Promise<AiNeighborhoodPOICounts> {
const entries = await Promise.all( // Single GROUP BY query replaces 6x individual COUNT queries.
CATEGORY_KEYS.map(async (cat) => { const rows = await prisma.$queryRaw<{ type: POIType; count: bigint }[]>`
const count = await prisma.pOI.count({ SELECT "type", COUNT(*) AS count
where: { FROM "POI"
district, WHERE "district" = ${district} AND "city" = ${city}
city, GROUP BY "type"
type: { in: CATEGORY_POI_TYPES[cat] }, `;
},
}); const typeCountMap = new Map<POIType, number>();
return [cat, count] as const; for (const row of rows) {
}), typeCountMap.set(row.type, Number(row.count));
); }
const entries = CATEGORY_KEYS.map((cat) => {
const total = CATEGORY_POI_TYPES[cat].reduce(
(sum, t) => sum + (typeCountMap.get(t) ?? 0),
0,
);
return [cat, total] as const;
});
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts; return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
} }