From 7bb2b093e5730be11b4da1349b4338e37f554a97 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 24 Jun 2026 18:51:54 +0000 Subject: [PATCH] perf(portfolio): only join EPC graph / plan LATERAL in count when a filter needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPropertiesCount returns the portfolio's total property count for pagination, but it dragged the whole read model through the COUNT: the property_details_epc + property_baseline_performance + two epc_property joins, plus a correlated default-plan LATERAL that ran once per property (31k+ plan lookups for a large portfolio). None of those joins change a COUNT (none multiply rows), so for an unfiltered load they were pure cost — pushing the query to ~14.7s, past Vercel's 15s limit (intermittent timeout on /api/properties). Join only what an active filter references: no filters -> plain COUNT over property; EPC/provenance filter -> add the epc-graph joins; Expected-EPC filter -> add the plan LATERAL. Unfiltered count 14,667ms -> 93ms (portfolio 796); provenance-filtered 156ms. getProperties is unchanged (its LIMIT 1000 already bounds the LATERALs). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/portfolio/[slug]/utils.ts | 52 ++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index b429f6c8..6014cc25 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -34,6 +34,7 @@ import { } from "@/lib/services/epcSources"; import { FilterGroups, + FilterField, PropertyFilter, PROPERTY_TYPE_OPTIONS, BUILT_FORM_OPTIONS, @@ -681,25 +682,62 @@ function buildWhereClause(filterGroups: FilterGroups): ReturnType { : sql``; } +// Filter fields whose SQL references the EPC graph (epc/bp/epl/epp) vs the +// default-plan LATERAL. The count query only needs a join when an active filter +// references it — otherwise joining is pure cost. The `pl` LATERAL in particular +// runs once per property (31k+ correlated plan lookups for a large portfolio), +// which alone pushed the unfiltered count past Vercel's 15s limit. +const EPC_JOIN_FILTER_FIELDS = new Set([ + "currentEpc", + "lodgedEpc", + "provenance", + "co2Emissions", + "floorArea", + "epcExpiryDate", + "mainfuel", +]); +const PLAN_JOIN_FILTER_FIELDS = new Set(["expectedEpc"]); + +function filterFieldsInUse(filterGroups: FilterGroups): Set { + const fields = new Set(); + for (const group of filterGroups) { + for (const cond of group.conditions) fields.add(cond.field); + } + return fields; +} + export async function getPropertiesCount( portfolioId: string, filterGroups: FilterGroups = [] ): Promise { const combinedWhere = buildWhereClause(filterGroups); + const fields = filterFieldsInUse(filterGroups); - const result = await db.execute<{ count: string }>(sql` - SELECT COUNT(DISTINCT p.id)::int AS count - FROM property p - LEFT JOIN property_details_epc epc ON epc.property_id = p.id - ${newApproachJoins} - LEFT JOIN LATERAL ( + // Only join what an active filter needs. COUNT is unaffected by the LEFT JOINs + // otherwise (none multiply rows), so omitting them is purely a speed-up. + const needsEpcJoins = [...fields].some((f) => EPC_JOIN_FILTER_FIELDS.has(f)); + const needsPlanJoin = [...fields].some((f) => PLAN_JOIN_FILTER_FIELDS.has(f)); + + const epcJoins = needsEpcJoins + ? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id + ${newApproachJoins}` + : sql``; + const planJoin = needsPlanJoin + ? sql`LEFT JOIN LATERAL ( SELECT id, post_sap_points FROM plan WHERE property_id = p.id AND portfolio_id = p.portfolio_id AND is_default = true ORDER BY created_at DESC LIMIT 1 - ) pl ON true + ) pl ON true` + : sql``; + + const result = await db.execute<{ count: string }>(sql` + SELECT COUNT(DISTINCT p.id)::int AS count + FROM property p + ${epcJoins} + ${planJoin} WHERE p.portfolio_id = ${portfolioId} ${combinedWhere} `);