Skip to content

Commit cb2db6d

Browse files
Merge pull request #739 from Muneerat/feature/reports-lookup-index
add compound index for reports lookup hot path
2 parents 70a46ae + 1aae1ef commit cb2db6d

4 files changed

Lines changed: 70 additions & 4 deletions

File tree

migrations/reports_index.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- up
2+
-- Compound index for the scheduled reports list hot path.
3+
-- The GET /api/reports/scheduled endpoint queries by user_id with ORDER BY created_at DESC.
4+
-- This index covers both filter and sort in a single btree scan, avoiding a separate sort step.
5+
CREATE INDEX IF NOT EXISTS scheduled_reports_user_created_at_idx
6+
ON scheduled_reports (user_id, created_at DESC);
7+
8+
-- down
9+
DROP INDEX IF EXISTS scheduled_reports_user_created_at_idx;

src/db/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,10 @@ export const scheduledReports = pgTable(
566566
(t) => ({
567567
scheduledReportsUserIdIdx: index("scheduled_reports_user_id_idx").on(t.userId),
568568
scheduledReportsActiveIdx: index("scheduled_reports_active_idx").on(t.active),
569+
scheduledReportsUserCreatedAtIdx: index("scheduled_reports_user_created_at_idx").on(
570+
t.userId,
571+
t.createdAt.desc(),
572+
),
569573
}),
570574
);
571575

src/routes/reports/scheduled.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
import { Router } from "express";
2626
import { z } from "zod";
27-
import { eq, desc } from "drizzle-orm";
27+
import { eq, desc, and } from "drizzle-orm";
2828
import { db } from "../../db";
2929
import { scheduledReports } from "../../db/schema";
3030
import { RouteErrorFactory } from "../../errors";
@@ -182,6 +182,20 @@ const listQuerySchema = z.object({
182182
.refine((val) => val > 0 && val <= 100, {
183183
message: "pageSize must be between 1 and 100",
184184
}),
185+
active: z
186+
.string()
187+
.optional()
188+
.refine(
189+
(val) => {
190+
if (val === undefined || val === "") return true;
191+
return ["true", "false", "1", "0"].includes(val);
192+
},
193+
{ message: "active must be 'true', 'false', '1', or '0'" },
194+
)
195+
.transform((val) => {
196+
if (val === undefined || val === "") return undefined;
197+
return val === "true" || val === "1";
198+
}),
185199
});
186200

187201
// ---------------------------------------------------------------------------
@@ -293,22 +307,27 @@ const listQuerySchema = z.object({
293307
throw RouteErrorFactory.badRequest("Invalid query parameters");
294308
}
295309

296-
const { page, pageSize } = parsed.data;
310+
const { page, pageSize, active } = parsed.data;
297311
const offset = (page - 1) * pageSize;
298312

313+
const whereConditions = [eq(scheduledReports.userId, userId)];
314+
if (active !== undefined) {
315+
whereConditions.push(eq(scheduledReports.active, active));
316+
}
317+
299318
// Fetch total count for pagination metadata
300319
const [countResult] = await db
301320
.select({ count: db.$count(scheduledReports) })
302321
.from(scheduledReports)
303-
.where(eq(scheduledReports.userId, userId));
322+
.where(and(...whereConditions));
304323

305324
const total = Number(countResult?.count ?? 0);
306325

307326
// Fetch paginated results
308327
const results = await db
309328
.select()
310329
.from(scheduledReports)
311-
.where(eq(scheduledReports.userId, userId))
330+
.where(and(...whereConditions))
312331
.orderBy(desc(scheduledReports.createdAt))
313332
.limit(pageSize)
314333
.offset(offset);

tests/scheduledReports.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,40 @@ describe("GET /api/reports/scheduled", () => {
466466
expect(res.status).toBe(200);
467467
expect(res.body.data).toEqual([]);
468468
});
469+
470+
it("filters by active=true", async () => {
471+
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:1}])})} as any);
472+
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);
473+
const res = await request(app).get("/api/reports/scheduled").query({active:"true"});
474+
expect(res.status).toBe(200);
475+
});
476+
477+
it("filters by active=false", async () => {
478+
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:1}])})} as any);
479+
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);
480+
const res = await request(app).get("/api/reports/scheduled").query({active:"false"});
481+
expect(res.status).toBe(200);
482+
});
483+
484+
it("returns 400 when active invalid", async () => {
485+
const res = await request(app).get("/api/reports/scheduled").query({active:"invalid"});
486+
expect(res.status).toBe(400);
487+
});
488+
489+
it("accepts active=1", async () => {
490+
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:0}])})} as any);
491+
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);
492+
const res = await request(app).get("/api/reports/scheduled").query({active:"1"});
493+
expect(res.status).toBe(200);
494+
});
495+
496+
it("accepts active=0", async () => {
497+
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:0}])})} as any);
498+
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);
499+
const res = await request(app).get("/api/reports/scheduled").query({active:"0"});
500+
expect(res.status).toBe(200);
501+
});
502+
469503
});
470504

471505
describe("GET /api/reports/scheduled/:id", () => {

0 commit comments

Comments
 (0)