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:
@@ -4,11 +4,16 @@ import {
|
||||
PrismaNeighborhoodScoreService,
|
||||
} 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', () => {
|
||||
let service: NeighborhoodScoreServiceImpl;
|
||||
let mockPrisma: {
|
||||
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> };
|
||||
|
||||
@@ -18,7 +23,7 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
pOI: { count: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
mockLogger = { log: vi.fn() };
|
||||
|
||||
@@ -60,44 +65,45 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
||||
});
|
||||
|
||||
describe('calculateAndSave', () => {
|
||||
it('calculates scores from POI counts and upserts', async () => {
|
||||
// Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%),
|
||||
// shopping=5 (50%), greenery=3 (50%), safety=2 (50%)
|
||||
const poiCountsByCategory = [15, 4, 6, 5, 3, 2];
|
||||
let callIndex = 0;
|
||||
mockPrisma.pOI.count.mockImplementation(() => {
|
||||
return Promise.resolve(poiCountsByCategory[callIndex++]!);
|
||||
});
|
||||
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
it('issues exactly one DB query and calculates scores correctly', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue(
|
||||
makePoiRows({
|
||||
SCHOOL: 10, UNIVERSITY: 5,
|
||||
HOSPITAL: 2, CLINIC: 2,
|
||||
METRO_STATION: 3, BUS_STOP: 3,
|
||||
MALL: 2, MARKET: 2, SUPERMARKET: 1,
|
||||
PARK: 3,
|
||||
POLICE_STATION: 1, FIRE_STATION: 1,
|
||||
}),
|
||||
);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
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.healthcareScore).toBe(5);
|
||||
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);
|
||||
});
|
||||
|
||||
it('caps category scores at 10', async () => {
|
||||
// All categories have way more POIs than max
|
||||
mockPrisma.pOI.count.mockResolvedValue(100);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
mockPrisma.$queryRaw.mockResolvedValue(
|
||||
makePoiRows({
|
||||
SCHOOL: 100, UNIVERSITY: 100, HOSPITAL: 100, CLINIC: 100,
|
||||
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');
|
||||
|
||||
// All scores capped at 10 → total = sum of weights = 100
|
||||
expect(result.educationScore).toBe(10);
|
||||
expect(result.healthcareScore).toBe(10);
|
||||
expect(result.transportScore).toBe(10);
|
||||
@@ -105,25 +111,27 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
||||
expect(result.greeneryScore).toBe(10);
|
||||
expect(result.safetyScore).toBe(10);
|
||||
expect(result.totalScore).toBe(100);
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 0 scores when no POIs exist', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
expect(result.educationScore).toBe(0);
|
||||
expect(result.totalScore).toBe(0);
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs the calculated score', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(5);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 }));
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
@@ -140,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
let prismaFallback: PrismaNeighborhoodScoreService;
|
||||
let mockPrisma: {
|
||||
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 mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
|
||||
@@ -148,7 +156,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
pOI: { count: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
mockAiClient = { scoreNeighborhood: vi.fn() };
|
||||
@@ -165,7 +173,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
});
|
||||
|
||||
it('persists AI service response when scoreNeighborhood succeeds', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(6);
|
||||
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 }));
|
||||
mockAiClient.scoreNeighborhood.mockResolvedValue({
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
@@ -179,7 +187,9 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
|
||||
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');
|
||||
|
||||
@@ -187,12 +197,15 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
expect(result.totalScore).toBe(71.2);
|
||||
expect(result.educationScore).toBe(8.5);
|
||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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'));
|
||||
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');
|
||||
|
||||
|
||||
@@ -143,18 +143,26 @@ async function countPOIs(
|
||||
district: string,
|
||||
city: string,
|
||||
): Promise<AiNeighborhoodPOICounts> {
|
||||
const entries = await Promise.all(
|
||||
CATEGORY_KEYS.map(async (cat) => {
|
||||
const count = await prisma.pOI.count({
|
||||
where: {
|
||||
district,
|
||||
city,
|
||||
type: { in: CATEGORY_POI_TYPES[cat] },
|
||||
},
|
||||
});
|
||||
return [cat, count] as const;
|
||||
}),
|
||||
);
|
||||
// Single GROUP BY query replaces 6x individual COUNT queries.
|
||||
const rows = await prisma.$queryRaw<{ type: POIType; count: bigint }[]>`
|
||||
SELECT "type", COUNT(*) AS count
|
||||
FROM "POI"
|
||||
WHERE "district" = ${district} AND "city" = ${city}
|
||||
GROUP BY "type"
|
||||
`;
|
||||
|
||||
const typeCountMap = new Map<POIType, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user