Skip to content

Commit 43b114a

Browse files
committed
fix: optimize for single choice
1 parent 30f0918 commit 43b114a

File tree

1 file changed

+74
-27
lines changed

1 file changed

+74
-27
lines changed

services/libs/data-access-layer/src/members/queryBuilder.ts

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@ const getOrderClause = (
127127
return `${fieldExpr} ${direction}`
128128
}
129129

130+
const detectPinnedMemberId = (filterString: string): { pinned: boolean; smallList: boolean } => {
131+
if (!filterString) return { pinned: false, smallList: false }
132+
133+
// m.id = '...'
134+
const eqRe = /\bm\.id\s*=\s*(?:'[^']+'|\$\([^)]+\)|:[a-zA-Z_]\w*|\?)/i
135+
if (eqRe.test(filterString)) return { pinned: true, smallList: true }
136+
137+
// m.id IN ( ... ) → estimate list size by counting commas (rough but effective)
138+
const inRe = /\bm\.id\s+IN\s*\(([^)]+)\)/i
139+
const m = inRe.exec(filterString)
140+
if (m && m[1]) {
141+
const items = m[1]
142+
.split(',')
143+
.map((s) => s.trim())
144+
.filter(Boolean)
145+
// Consider "small" lists up to ~100 items; tune if needed.
146+
return { pinned: true, smallList: items.length <= 100 }
147+
}
148+
149+
return { pinned: false, smallList: false }
150+
}
151+
130152
export const buildQuery = ({
131153
fields,
132154
withAggregates,
@@ -141,38 +163,68 @@ export const buildQuery = ({
141163
const fallbackDir: OrderDirection = orderDirection || 'DESC'
142164
const { field: sortField, direction } = parseOrderBy(orderBy, fallbackDir)
143165

144-
// If filter references mo.*, we must ensure member_orgs is joined in the outer query.
166+
// Detect alias usage in filters (to decide joins/CTEs needed outside)
145167
const filterHasMo = filterString.includes('mo.')
168+
const filterHasMe = filterString.includes('me.')
146169
const needsMemberOrgs = includeMemberOrgs || filterHasMo
147170

148-
// We use the optimized path when:
149-
// - aggregates are requested (msa available)
150-
// - sorting is by activityCount (or not specified → default)
151-
// NOTE: we DO NOT check for mo./me. here because we keep all filters OUTSIDE the CTE.
152-
const useActivityCountOptimized = withAggregates && (!sortField || sortField === 'activityCount')
171+
// If filters pin m.id to a single value or a small IN-list, skip top-N entirely.
172+
const { pinned, smallList } = detectPinnedMemberId(filterString)
173+
const useDirectIdPath = withAggregates && pinned && smallList
174+
175+
// Default sort clause for fallback/outer queries
176+
const orderClause = getOrderClause(sortField, direction, withAggregates)
177+
178+
if (useDirectIdPath) {
179+
// Direct path: start from memberSegmentsAgg keyed by (memberId, segmentId)
180+
const ctes: string[] = []
181+
if (needsMemberOrgs) ctes.push(buildMemberOrgsCTE(true).trim())
182+
183+
const withClause = ctes.length ? `WITH ${ctes.join(',\n')}` : ''
153184

154-
log.info(`buildQuery: useActivityCountOptimized=${useActivityCountOptimized}`)
185+
const memberOrgsJoin = needsMemberOrgs ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id` : ''
186+
187+
// NOTE:
188+
// - We do NOT include member_search here; an ID-pin makes it redundant.
189+
// - We keep the full filterString (it already contains the m.id predicate).
190+
// - This path leverages the UNIQUE (memberId, segmentId) index for O(1) lookups.
191+
return `
192+
${withClause}
193+
SELECT ${fields}
194+
FROM "memberSegmentsAgg" msa
195+
JOIN members m
196+
ON m.id = msa."memberId"
197+
${memberOrgsJoin}
198+
LEFT JOIN "memberEnrichments" me
199+
ON me."memberId" = m.id
200+
WHERE
201+
msa."segmentId" = $(segmentId)
202+
AND (${filterString})
203+
ORDER BY ${orderClause} NULLS LAST
204+
LIMIT ${limit}
205+
OFFSET ${offset}
206+
`.trim()
207+
}
208+
209+
// Decide if we can use the activityCount-optimized path
210+
const useActivityCountOptimized =
211+
withAggregates && (!sortField || sortField === 'activityCount') && !filterHasMe
212+
// (we do allow mo.* now, but only outside the CTE; see below)
155213

156214
if (useActivityCountOptimized) {
157215
const ctes: string[] = []
158216

159-
// Include member_orgs CTE only if we need it in the OUTER query (never inside top_members)
160-
if (needsMemberOrgs) {
161-
ctes.push(buildMemberOrgsCTE(true).trim())
162-
}
217+
// Include member_orgs CTE only for the OUTER query (never filter inside the CTE)
218+
if (needsMemberOrgs) ctes.push(buildMemberOrgsCTE(true).trim())
163219

164-
// Include search CTE if present
165-
if (searchConfig.cte) {
166-
ctes.push(searchConfig.cte.trim())
167-
}
220+
// Include search CTE if present, but join it to msa inside top_members via memberId
221+
if (searchConfig.cte) ctes.push(searchConfig.cte.trim())
168222

169-
// Join search to msa WITHOUT touching members (so index on msa can be used)
170223
const searchJoinForTopMembers = searchConfig.cte
171224
? `\n INNER JOIN member_search ms ON ms."memberId" = msa."memberId"`
172225
: ''
173226

174-
// Oversample: fetch more rows than needed before applying outer filters,
175-
// then apply LIMIT/OFFSET on the final ordered result.
227+
// Oversample to keep page filled after outer filters; tune multiplier if needed
176228
const oversampleMultiplier = 5
177229
const prefetch = Math.max(limit * oversampleMultiplier + offset, limit + offset)
178230

@@ -194,13 +246,9 @@ export const buildQuery = ({
194246
)
195247

196248
const withClause = `WITH ${ctes.join(',\n')}`
197-
198249
const memberOrgsJoin = needsMemberOrgs ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id` : ''
199250

200-
// IMPORTANT:
201-
// - All filters on members/orgs/enrichments are applied OUTSIDE the CTE.
202-
// - Final ORDER BY keeps activityCount (already aligned with top_members).
203-
// - Final LIMIT/OFFSET ensure correct pagination after applying filters.
251+
// Outer filters (including mo./me.) applied here; index handles the CTE ranking
204252
return `
205253
${withClause}
206254
SELECT ${fields}
@@ -221,7 +269,7 @@ export const buildQuery = ({
221269
`.trim()
222270
}
223271

224-
// Fallback path: any other sort mode.
272+
// Fallback path (other sorts / non-aggregate)
225273
const baseCtes = [needsMemberOrgs ? buildMemberOrgsCTE(true) : '', searchConfig.cte].filter(
226274
Boolean,
227275
)
@@ -235,10 +283,8 @@ export const buildQuery = ({
235283
searchConfig.join,
236284
].filter(Boolean)
237285

238-
const orderClause = getOrderClause(sortField, direction, withAggregates)
239-
240286
return `
241-
${baseCtes.length > 0 ? `WITH ${baseCtes.join(',\n')}` : ''}
287+
${baseCtes.length ? `WITH ${baseCtes.join(',\n')}` : ''}
242288
SELECT ${fields}
243289
FROM members m
244290
${joins.join('\n')}
@@ -248,6 +294,7 @@ export const buildQuery = ({
248294
OFFSET ${offset}
249295
`.trim()
250296
}
297+
251298
export const buildCountQuery = ({
252299
withAggregates,
253300
searchConfig,

0 commit comments

Comments
 (0)