From bbf6f8f298d7fd081b1534d3abd011ad25deb1a2 Mon Sep 17 00:00:00 2001 From: Oladipo Munirat Mopelola Date: Wed, 29 Jul 2026 00:13:49 +0000 Subject: [PATCH] add compound index for reports lookup hot path --- migrations/reports_index.sql | 9 +++++++++ src/db/schema.ts | 4 ++++ src/routes/reports/scheduled.ts | 27 ++++++++++++++++++++++---- tests/scheduledReports.test.ts | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 migrations/reports_index.sql diff --git a/migrations/reports_index.sql b/migrations/reports_index.sql new file mode 100644 index 00000000..3a96bad2 --- /dev/null +++ b/migrations/reports_index.sql @@ -0,0 +1,9 @@ +-- up +-- Compound index for the scheduled reports list hot path. +-- The GET /api/reports/scheduled endpoint queries by user_id with ORDER BY created_at DESC. +-- This index covers both filter and sort in a single btree scan, avoiding a separate sort step. +CREATE INDEX IF NOT EXISTS scheduled_reports_user_created_at_idx + ON scheduled_reports (user_id, created_at DESC); + +-- down +DROP INDEX IF EXISTS scheduled_reports_user_created_at_idx; \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 3d54008c..38447a8a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -556,6 +556,10 @@ export const scheduledReports = pgTable( (t) => ({ scheduledReportsUserIdIdx: index("scheduled_reports_user_id_idx").on(t.userId), scheduledReportsActiveIdx: index("scheduled_reports_active_idx").on(t.active), + scheduledReportsUserCreatedAtIdx: index("scheduled_reports_user_created_at_idx").on( + t.userId, + t.createdAt.desc(), + ), }), ); diff --git a/src/routes/reports/scheduled.ts b/src/routes/reports/scheduled.ts index 6ab24104..ddc0c7d4 100644 --- a/src/routes/reports/scheduled.ts +++ b/src/routes/reports/scheduled.ts @@ -24,7 +24,7 @@ import { Router } from "express"; import { z } from "zod"; -import { eq, desc } from "drizzle-orm"; +import { eq, desc, and } from "drizzle-orm"; import { db } from "../../db"; import { scheduledReports } from "../../db/schema"; import { RouteErrorFactory } from "../../errors"; @@ -182,6 +182,20 @@ const listQuerySchema = z.object({ .refine((val) => val > 0 && val <= 100, { message: "pageSize must be between 1 and 100", }), + active: z + .string() + .optional() + .refine( + (val) => { + if (val === undefined || val === "") return true; + return ["true", "false", "1", "0"].includes(val); + }, + { message: "active must be 'true', 'false', '1', or '0'" }, + ) + .transform((val) => { + if (val === undefined || val === "") return undefined; + return val === "true" || val === "1"; + }), }); // --------------------------------------------------------------------------- @@ -293,14 +307,19 @@ const listQuerySchema = z.object({ throw RouteErrorFactory.badRequest("Invalid query parameters"); } - const { page, pageSize } = parsed.data; + const { page, pageSize, active } = parsed.data; const offset = (page - 1) * pageSize; + const whereConditions = [eq(scheduledReports.userId, userId)]; + if (active !== undefined) { + whereConditions.push(eq(scheduledReports.active, active)); + } + // Fetch total count for pagination metadata const [countResult] = await db .select({ count: db.$count(scheduledReports) }) .from(scheduledReports) - .where(eq(scheduledReports.userId, userId)); + .where(and(...whereConditions)); const total = Number(countResult?.count ?? 0); @@ -308,7 +327,7 @@ const listQuerySchema = z.object({ const results = await db .select() .from(scheduledReports) - .where(eq(scheduledReports.userId, userId)) + .where(and(...whereConditions)) .orderBy(desc(scheduledReports.createdAt)) .limit(pageSize) .offset(offset); diff --git a/tests/scheduledReports.test.ts b/tests/scheduledReports.test.ts index afb83e99..57ec69c1 100644 --- a/tests/scheduledReports.test.ts +++ b/tests/scheduledReports.test.ts @@ -466,6 +466,40 @@ describe("GET /api/reports/scheduled", () => { expect(res.status).toBe(200); expect(res.body.data).toEqual([]); }); + + it("filters by active=true", async () => { + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:1}])})} as any); + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any); + const res = await request(app).get("/api/reports/scheduled").query({active:"true"}); + expect(res.status).toBe(200); + }); + + it("filters by active=false", async () => { + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:1}])})} as any); + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any); + const res = await request(app).get("/api/reports/scheduled").query({active:"false"}); + expect(res.status).toBe(200); + }); + + it("returns 400 when active invalid", async () => { + const res = await request(app).get("/api/reports/scheduled").query({active:"invalid"}); + expect(res.status).toBe(400); + }); + + it("accepts active=1", async () => { + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:0}])})} as any); + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any); + const res = await request(app).get("/api/reports/scheduled").query({active:"1"}); + expect(res.status).toBe(200); + }); + + it("accepts active=0", async () => { + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:0}])})} as any); + mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any); + const res = await request(app).get("/api/reports/scheduled").query({active:"0"}); + expect(res.status).toBe(200); + }); + }); describe("GET /api/reports/scheduled/:id", () => {