perf(portfolio): only join EPC graph / plan LATERAL in count when a filter needs it

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-24 18:51:54 +00:00
parent ea988cfe52
commit 7bb2b093e5

View file

@ -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<typeof sql> {
: 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<FilterField>([
"currentEpc",
"lodgedEpc",
"provenance",
"co2Emissions",
"floorArea",
"epcExpiryDate",
"mainfuel",
]);
const PLAN_JOIN_FILTER_FIELDS = new Set<FilterField>(["expectedEpc"]);
function filterFieldsInUse(filterGroups: FilterGroups): Set<FilterField> {
const fields = new Set<FilterField>();
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<number> {
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}
`);