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,
|
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');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user