@@ -132,8 +132,7 @@ export const MEMBER_INSERT_COLUMNS = [
132132]
133133
134134const QUERY_FILTER_COLUMN_MAP : Map < string , { name : string ; queryable ?: boolean } > = new Map ( [
135- [ 'activityCount' , { name : 'coalesce(msa."activityCount", 0)::integer' } ] ,
136- [ 'activityCount' , { name : 'coalesce(msa."activityCount", 0)::integer' } ] ,
135+ [ 'activityCount' , { name : 'msa."activityCount"' } ] ,
137136 [ 'attributes' , { name : 'm.attributes' } ] ,
138137 [ 'averageSentiment' , { name : 'coalesce(msa."averageSentiment", 0)::decimal' } ] ,
139138 [ 'displayName' , { name : 'm."displayName"' } ] ,
@@ -151,26 +150,6 @@ const QUERY_FILTER_COLUMN_MAP: Map<string, { name: string; queryable?: boolean }
151150 [ 'segmentId' , { name : 'msa."segmentId"' } ] ,
152151] )
153152
154- // const QUERY_FILTER_ATTRIBUTE_MAP = ['avatarUrl', 'isBot', 'isTeamMember', 'jobTitle']
155-
156- const getOrderClause = ( orderBy : string , withAggregates : boolean ) : string => {
157- const defaultOrder = withAggregates ? 'msa."activityCount" DESC' : 'm."joinedAt" DESC'
158-
159- if ( ! orderBy || typeof orderBy !== 'string' || ! orderBy . length ) {
160- return defaultOrder
161- }
162-
163- const [ fieldName , direction = 'DESC' ] = orderBy . split ( '_' )
164- const orderField = QUERY_FILTER_COLUMN_MAP . get ( fieldName ) ?. name
165-
166- if ( ! orderField ) {
167- return defaultOrder
168- }
169-
170- const orderDirection = [ 'DESC' , 'ASC' ] . includes ( direction ) ? direction : 'DESC'
171- return `${ orderField } ${ orderDirection } `
172- }
173-
174153const buildSearchCTE = (
175154 search : string ,
176155) : { cte : string ; join : string ; params : Record < string , string > } => {
@@ -190,7 +169,8 @@ const buildSearchCTE = (
190169 (mi.verified = true AND mi.type = $(emailType) AND LOWER(mi."value") LIKE $(searchPattern))
191170 OR LOWER(m."displayName") LIKE $(searchPattern)
192171 )
193- )` ,
172+ )
173+ ` ,
194174 join : `INNER JOIN member_search ms ON ms."memberId" = m.id` ,
195175 params : {
196176 emailType : MemberIdentityType . EMAIL ,
@@ -210,33 +190,236 @@ const buildMemberOrgsCTE = (includeMemberOrgs: boolean): string => {
210190 FROM "memberOrganizations"
211191 WHERE "deletedAt" IS NULL
212192 GROUP BY "memberId"
213- )`
193+ )
194+ `
195+ }
196+
197+ type OrderDirection = 'ASC' | 'DESC'
198+
199+ interface SearchConfig {
200+ cte : string
201+ join : string
202+ }
203+
204+ interface BuildQueryArgs {
205+ fields : string
206+ withAggregates : boolean
207+ includeMemberOrgs : boolean
208+ searchConfig : SearchConfig
209+ filterString : string
210+ orderBy ?: string // e.g. "activityCount_DESC", "score_ASC", "joinedAt"
211+ orderDirection ?: OrderDirection
212+ limit ?: number
213+ offset ?: number
214+ }
215+
216+ const ORDER_FIELD_MAP : Record < string , string > = {
217+ activityCount : 'msa."activityCount"' ,
218+ score : 'm."score"' ,
219+ joinedAt : 'm."joinedAt"' ,
220+ displayName : 'm."displayName"' ,
221+ }
222+
223+ const parseOrderBy = (
224+ orderBy : string | undefined ,
225+ fallbackDirection : OrderDirection ,
226+ ) : { field ?: string ; direction : OrderDirection } => {
227+ if ( ! orderBy || ! orderBy . trim ( ) ) {
228+ return { field : undefined , direction : fallbackDirection }
229+ }
230+
231+ const [ rawField , rawDir ] = orderBy . trim ( ) . split ( '_' )
232+ const field = rawField ?. trim ( ) || undefined
233+
234+ const dir = ( rawDir || '' ) . toUpperCase ( )
235+ const direction : OrderDirection =
236+ dir === 'ASC' || dir === 'DESC'
237+ ? ( dir as OrderDirection )
238+ : fallbackDirection
239+
240+ return { field, direction }
214241}
215242
216- const buildQuery = (
217- fields : string ,
243+ const getOrderClause = (
244+ parsedField : string | undefined ,
245+ direction : OrderDirection ,
218246 withAggregates : boolean ,
219- includeMemberOrgs : boolean ,
220- searchConfig : { cte : string ; join : string } ,
221- filterString : string ,
222247) : string => {
223- const ctes = [ buildMemberOrgsCTE ( includeMemberOrgs ) , searchConfig . cte ] . filter ( Boolean )
248+ const defaultOrder = withAggregates
249+ ? 'msa."activityCount" DESC'
250+ : 'm."joinedAt" DESC'
251+
252+ if ( ! parsedField ) return defaultOrder
253+
254+ const fieldExpr = ORDER_FIELD_MAP [ parsedField ]
255+ if ( ! fieldExpr ) return defaultOrder
256+
257+ return `${ fieldExpr } ${ direction } `
258+ }
259+
260+ const buildQuery = ( {
261+ fields,
262+ withAggregates,
263+ includeMemberOrgs,
264+ searchConfig,
265+ filterString,
266+ orderBy,
267+ orderDirection,
268+ limit = 20 ,
269+ offset = 0 ,
270+ } : BuildQueryArgs ) : string => {
271+ const fallbackDir : OrderDirection = orderDirection || 'DESC'
272+ const { field : sortField , direction } = parseOrderBy ( orderBy , fallbackDir )
273+
274+ // Detect if filters reference extra aliases.
275+ const filterHasMo = filterString . includes ( 'mo.' )
276+ const filterHasMe = filterString . includes ( 'me.' )
277+
278+ // If filter references mo.*, we must ensure member_orgs is joined.
279+ const needsMemberOrgs = includeMemberOrgs || filterHasMo
280+
281+ // Optimized path is only safe if:
282+ // - withAggregates is true
283+ // - sort is by activityCount (or default)
284+ // - filter does NOT reference mo. or me. (those aliases do not exist in top_members)
285+ const useActivityCountOptimized =
286+ withAggregates &&
287+ ! filterHasMo &&
288+ ! filterHasMe &&
289+ ( ! sortField || sortField === 'activityCount' )
290+
291+ if ( useActivityCountOptimized ) {
292+ log . info ( `Using optimized activityCount path` )
293+ const ctes : string [ ] = [ ]
294+
295+ // For optimized path:
296+ // - We MAY include member_orgs CTE only if includeMemberOrgs is true.
297+ // - But filterString is guaranteed not to reference mo/me here.
298+ if ( includeMemberOrgs ) {
299+ const memberOrgsCTE = buildMemberOrgsCTE ( true )
300+ ctes . push ( memberOrgsCTE . trim ( ) )
301+ }
302+
303+ if ( searchConfig . cte ) {
304+ ctes . push ( searchConfig . cte . trim ( ) )
305+ }
306+
307+ const searchJoinInTopMembers = searchConfig . join
308+ ? `\n ${ searchConfig . join } ` // INNER JOIN member_search ms ON ms."memberId" = m.id
309+ : ''
310+
311+ ctes . push ( `
312+ top_members AS (
313+ SELECT
314+ msa."memberId"
315+ FROM "memberSegmentsAgg" msa
316+ JOIN members m ON m.id = msa."memberId"
317+ ${ searchJoinInTopMembers }
318+ WHERE
319+ msa."segmentId" = $(segmentId)
320+ AND (${ filterString } )
321+ ORDER BY
322+ msa."activityCount" ${ direction } NULLS LAST
323+ LIMIT ${ limit } OFFSET ${ offset }
324+ )
325+ ` . trim ( ) )
326+
327+ const withClause = `WITH ${ ctes . join ( ',\n' ) } `
328+
329+ const memberOrgsJoin = includeMemberOrgs
330+ ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id`
331+ : ''
332+
333+ return `
334+ ${ withClause }
335+ SELECT ${ fields }
336+ FROM top_members tm
337+ JOIN members m
338+ ON m.id = tm."memberId"
339+ INNER JOIN "memberSegmentsAgg" msa
340+ ON msa."memberId" = m.id
341+ AND msa."segmentId" = $(segmentId)
342+ ${ memberOrgsJoin }
343+ LEFT JOIN "memberEnrichments" me
344+ ON me."memberId" = m.id
345+ WHERE (${ filterString } )
346+ ORDER BY
347+ msa."activityCount" ${ direction } NULLS LAST
348+ ` . trim ( )
349+ }
350+ else {
351+ log . info ( `Not using optimized activityCount path` )
352+ }
353+
354+ // Fallback path: any case that is not safe/eligible for optimization.
355+ // Here we MUST align joins with what filterString references.
356+ const baseCtes = [
357+ needsMemberOrgs ? buildMemberOrgsCTE ( true ) : '' ,
358+ searchConfig . cte ,
359+ ] . filter ( Boolean )
224360
225361 const joins = [
226362 withAggregates
227363 ? `INNER JOIN "memberSegmentsAgg" msa ON msa."memberId" = m.id AND msa."segmentId" = $(segmentId)`
228364 : '' ,
229- includeMemberOrgs ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id` : '' ,
365+ needsMemberOrgs
366+ ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id`
367+ : '' ,
230368 `LEFT JOIN "memberEnrichments" me ON me."memberId" = m.id` ,
231369 searchConfig . join ,
232370 ] . filter ( Boolean )
233371
372+ const orderClause = getOrderClause ( sortField , direction , withAggregates )
373+
234374 return `
235- ${ ctes . length > 0 ? `WITH ${ ctes . join ( ',\n' ) } ` : '' }
375+ ${ baseCtes . length > 0 ? `WITH ${ baseCtes . join ( ',\n' ) } ` : '' }
236376 SELECT ${ fields }
237377 FROM members m
238378 ${ joins . join ( '\n' ) }
239379 WHERE (${ filterString } )
380+ ORDER BY ${ orderClause } NULLS LAST
381+ LIMIT ${ limit }
382+ OFFSET ${ offset }
383+ ` . trim ( )
384+ }
385+
386+ interface BuildCountQueryArgs {
387+ withAggregates : boolean
388+ searchConfig : SearchConfig
389+ filterString : string
390+ includeMemberOrgs ?: boolean
391+ }
392+
393+ const buildCountQuery = ( {
394+ withAggregates,
395+ searchConfig,
396+ filterString,
397+ includeMemberOrgs = false ,
398+ } : BuildCountQueryArgs ) : string => {
399+ const filterHasMo = filterString . includes ( 'mo.' )
400+ const needsMemberOrgs = includeMemberOrgs || filterHasMo
401+
402+ const ctes = [
403+ needsMemberOrgs ? buildMemberOrgsCTE ( true ) : '' ,
404+ searchConfig . cte ,
405+ ] . filter ( Boolean )
406+
407+ const joins = [
408+ withAggregates
409+ ? `INNER JOIN "memberSegmentsAgg" msa ON msa."memberId" = m.id AND msa."segmentId" = $(segmentId)`
410+ : '' ,
411+ needsMemberOrgs
412+ ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id`
413+ : '' ,
414+ searchConfig . join ,
415+ ] . filter ( Boolean )
416+
417+ return `
418+ ${ ctes . length > 0 ? `WITH ${ ctes . join ( ',\n' ) } ` : '' }
419+ SELECT COUNT(DISTINCT m.id) AS count
420+ FROM members m
421+ ${ joins . join ( '\n' ) }
422+ WHERE (${ filterString } )
240423 ` . trim ( )
241424}
242425
@@ -339,7 +522,7 @@ export async function queryMembersAdvanced(
339522 search = null ,
340523 limit = 20 ,
341524 offset = 0 ,
342- orderBy = 'joinedAt_DESC ' ,
525+ orderBy = 'activityCount_DESC ' ,
343526 segmentId = undefined ,
344527 countOnly = false ,
345528 fields = [ ...QUERY_FILTER_COLUMN_MAP . keys ( ) ] ,
@@ -361,6 +544,7 @@ export async function queryMembersAdvanced(
361544 attributeSettings = [ ] as IDbMemberAttributeSetting [ ] ,
362545 } ,
363546) : Promise < PageData < IDbMemberData > > {
547+
364548 const startTime = Date . now ( )
365549
366550 const withAggregates = ! ! segmentId
@@ -405,13 +589,12 @@ export async function queryMembersAdvanced(
405589 )
406590
407591 // Build queries
408- const countQuery = buildQuery (
409- 'COUNT(*) as count' ,
410- withAggregates ,
411- include . memberOrganizations ,
412- searchConfig ,
413- filterString ,
414- )
592+ const countQuery = buildCountQuery ( {
593+ withAggregates,
594+ searchConfig,
595+ filterString,
596+ includeMemberOrgs : include . memberOrganizations ,
597+ } )
415598
416599 if ( countOnly ) {
417600 const result = await qx . selectOne ( countQuery , params )
@@ -443,21 +626,19 @@ export async function queryMembersAdvanced(
443626 . join ( ',\n' )
444627 log . info ( `[PERF] Field preparation took: ${ Date . now ( ) - fieldsStartTime } ms` )
445628
446- const mainQuery = `
447- ${ buildQuery (
448- preparedFields ,
449- withAggregates ,
450- include . memberOrganizations ,
451- searchConfig ,
452- filterString ,
453- ) }
454- ORDER BY ${ getOrderClause ( orderBy , withAggregates ) } NULLS LAST
455- LIMIT $(limit)
456- OFFSET $(offset)
457- `
458-
459- log . info ( `main query: ${ formatSql ( mainQuery , params ) } ` )
460- log . info ( `count query: ${ formatSql ( countQuery , params ) } ` )
629+ const mainQuery = buildQuery ( {
630+ fields : preparedFields ,
631+ withAggregates, // true when you need memberSegmentsAgg
632+ includeMemberOrgs : include . memberOrganizations ,
633+ searchConfig,
634+ filterString,
635+ orderBy, // e.g. 'activityCount' | 'score' | 'joinedAt'
636+ limit,
637+ offset,
638+ } )
639+
640+ // log.info(`main query: ${formatSql(mainQuery, params)}`)
641+ // log.info(`count query: ${formatSql(countQuery, params)}`)
461642
462643 // Execute queries in parallel
463644 const mainQueryStartTime = Date . now ( )
0 commit comments