Skip to content

Commit 7682bac

Browse files
awaseemjoshenlimavallete
authored
Fix: Updated to use optimized search columns when getting user counts (supabase#40107)
* Updated to use optimized search columns when getting user counts * added unit tests for query builders * minor * Nit fix keywords URL param not getting loaded into search input field * removed user footer * updated tests for upstream * updated integration tests * updated tests to be int for paginated * updated schema * updated types * updated type imports * updated type imports * Apply suggestion from @avallete Co-authored-by: Andrew Valleteau <[email protected]> * updated literal import * refactor: use common escaping (supabase#40186) refactor: use utils for sql escaping --------- Co-authored-by: Joshen Lim <[email protected]> Co-authored-by: Andrew Valleteau <[email protected]>
1 parent 13d9952 commit 7682bac

File tree

14 files changed

+1042
-88
lines changed

14 files changed

+1042
-88
lines changed

apps/studio/components/interfaces/Auth/Users/Users.constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { BASE_PATH } from 'lib/constants'
22
import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
3+
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
34

45
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
56

7+
export type SpecificFilterColumn = OptimizedSearchColumns | 'freeform'
8+
69
export const UUIDV4_LEFT_PREFIX_REGEX =
710
/^(?:[0-9a-f]{1,8}|[0-9a-f]{8}-|[0-9a-f]{8}-[0-9a-f]{1,4}|[0-9a-f]{8}-[0-9a-f]{4}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{0,3}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{0,3}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{0,12})$/i
811

apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@ import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-esti
22
import { HelpCircle, Loader2 } from 'lucide-react'
33
import { useEffect, useState } from 'react'
44

5+
import type { Filter, SpecificFilterColumn } from './Users.constants'
6+
7+
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
58
import { useParams } from 'common'
69
import { formatEstimatedCount } from 'components/grid/components/footer/pagination/Pagination.utils'
710
import { useUsersCountQuery } from 'data/auth/users-count-query'
811
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
912
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
1013
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
11-
import { Filter } from './Users.constants'
1214

1315
interface UsersFooterProps {
1416
filter: Filter
1517
filterKeywords: string
1618
selectedProviders: string[]
17-
specificFilterColumn: string
19+
specificFilterColumn: SpecificFilterColumn
1820
}
1921

2022
export const UsersFooter = ({
@@ -42,6 +44,10 @@ export const UsersFooter = ({
4244
filter: filter === 'all' ? undefined : filter,
4345
providers: selectedProviders,
4446
forceExactCount,
47+
// Use optimized search when filtering by specific column
48+
...(specificFilterColumn !== 'freeform'
49+
? { column: specificFilterColumn as OptimizedSearchColumns }
50+
: { column: undefined }),
4551
},
4652
{ keepPreviousData: true }
4753
)

apps/studio/components/interfaces/Auth/Users/UsersV2.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
55
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
66
import { toast } from 'sonner'
77

8+
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
9+
import type { SpecificFilterColumn } from './Users.constants'
810
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
911
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
1012
import AlertError from 'components/ui/AlertError'
@@ -91,9 +93,9 @@ export const UsersV2 = () => {
9193
}
9294
}, [showEmailPhoneColumns])
9395

94-
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState(
96+
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState<SpecificFilterColumn>(
9597
'filter',
96-
parseAsStringEnum(['id', 'email', 'phone', 'freeform']).withDefault('id')
98+
parseAsStringEnum<SpecificFilterColumn>(['id', 'email', 'phone', 'freeform']).withDefault('id')
9799
)
98100
const [filterUserType, setFilterUserType] = useQueryState(
99101
'userType',
@@ -138,7 +140,7 @@ export const UsersV2 = () => {
138140
)
139141

140142
const [columns, setColumns] = useState<Column<any>[]>([])
141-
const [search, setSearch] = useState('')
143+
const [search, setSearch] = useState(filterKeywords)
142144
const [selectedUser, setSelectedUser] = useState<string>()
143145
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
144146
const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
@@ -187,7 +189,7 @@ export const UsersV2 = () => {
187189
sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone',
188190
order: sortOrder as 'asc' | 'desc',
189191
...(specificFilterColumn !== 'freeform'
190-
? { column: specificFilterColumn }
192+
? { column: specificFilterColumn as OptimizedSearchColumns }
191193
: { column: undefined }),
192194
},
193195
{

apps/studio/data/auth/keys.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
2+
13
export const authKeys = {
24
users: (
35
projectRef: string | undefined,
@@ -39,6 +41,7 @@ export const authKeys = {
3941
filter: string | undefined
4042
providers: string[] | undefined
4143
forceExactCount?: boolean
44+
column?: OptimizedSearchColumns
4245
}
4346
) =>
4447
['projects', projectRef, 'users-count', ...(params ? [params].filter(Boolean) : [])] as const,

apps/studio/data/auth/users-count-query.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
12
import { getUsersCountSQL } from '@supabase/pg-meta/src/sql/studio/get-users-count'
23
import { useQuery } from '@tanstack/react-query'
34

@@ -13,6 +14,9 @@ type UsersCountVariables = {
1314
filter?: Filter
1415
providers?: string[]
1516
forceExactCount?: boolean
17+
18+
/** If set, uses optimized prefix search for the specified column */
19+
column?: OptimizedSearchColumns
1620
}
1721

1822
export async function getUsersCount(
@@ -23,10 +27,11 @@ export async function getUsersCount(
2327
filter,
2428
providers,
2529
forceExactCount,
30+
column,
2631
}: UsersCountVariables,
2732
signal?: AbortSignal
2833
) {
29-
const sql = getUsersCountSQL({ filter, keywords, providers, forceExactCount })
34+
const sql = getUsersCountSQL({ filter, keywords, providers, forceExactCount, column })
3035

3136
const { result } = await executeSql(
3237
{
@@ -63,6 +68,7 @@ export const useUsersCountQuery = <TData = UsersCountData>(
6368
filter,
6469
providers,
6570
forceExactCount,
71+
column,
6672
}: UsersCountVariables,
6773
{ enabled = true, ...options }: UseCustomQueryOptions<UsersCountData, UsersCountError, TData> = {}
6874
) =>
@@ -72,6 +78,7 @@ export const useUsersCountQuery = <TData = UsersCountData>(
7278
filter,
7379
providers,
7480
forceExactCount,
81+
column,
7582
}),
7683
queryFn: ({ signal }) =>
7784
getUsersCount(
@@ -82,6 +89,7 @@ export const useUsersCountQuery = <TData = UsersCountData>(
8289
filter,
8390
providers,
8491
forceExactCount,
92+
column,
8593
},
8694
signal
8795
),

apps/studio/data/auth/users-infinite-query.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getPaginatedUsersSQL } from '@supabase/pg-meta/src/sql/studio/get-users-paginated'
22
import { useInfiniteQuery } from '@tanstack/react-query'
33

4+
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
45
import type { components } from 'data/api'
56
import { executeSql, ExecuteSqlError } from 'data/sql/execute-sql-query'
67
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
@@ -20,8 +21,8 @@ type UsersVariables = {
2021
providers?: string[]
2122
sort?: 'id' | 'created_at' | 'email' | 'phone' | 'last_sign_in_at'
2223
order?: 'asc' | 'desc'
23-
24-
column?: 'id' | 'email' | 'phone'
24+
/** If set, uses optimized prefix search for the specified column */
25+
column?: OptimizedSearchColumns
2526
startAt?: string
2627
}
2728

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export function prefixToUUID(prefix: string, max: boolean) {
2+
const mapped = '00000000-0000-0000-0000-000000000000'
3+
.split('')
4+
.map((c, i) => (c === '-' ? c : prefix[i] ?? c))
5+
6+
if (prefix.length >= mapped.length) {
7+
return mapped.join('')
8+
}
9+
10+
if (prefix.length && prefix.length < 15) {
11+
mapped[14] = '4'
12+
}
13+
14+
if (prefix.length && prefix.length < 20) {
15+
mapped[19] = max ? 'b' : '8'
16+
}
17+
18+
if (max) {
19+
for (let i = prefix.length; i < mapped.length; i += 1) {
20+
if (mapped[i] === '0') {
21+
mapped[i] = 'f'
22+
}
23+
}
24+
}
25+
26+
return mapped.join('')
27+
}
28+
29+
export function stringRange(prefix: string): [string, string | undefined] {
30+
if (!prefix) {
31+
return [prefix, undefined]
32+
}
33+
34+
const lastCharCode = prefix.charCodeAt(prefix.length - 1)
35+
const TILDE_CHAR_CODE = 126 // '~'
36+
const Z_CHAR_CODE = 122 // 'z'
37+
38+
// 'z' (122): append '~' to avoid PostgreSQL collation issues with '{'
39+
if (lastCharCode === Z_CHAR_CODE) {
40+
return [prefix, prefix + '~']
41+
}
42+
43+
// '~' (126) or beyond: append space since we can't increment further
44+
if (lastCharCode >= TILDE_CHAR_CODE) {
45+
return [prefix, prefix + ' ']
46+
}
47+
48+
// All other characters: increment the last character
49+
const upperBound = prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastCharCode + 1)
50+
return [prefix, upperBound]
51+
}

packages/pg-meta/src/sql/studio/get-users-count.ts

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import type { OptimizedSearchColumns } from './get-users-types'
12
import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from './get-count-estimate'
3+
import { stringRange, prefixToUUID } from './get-users-common'
4+
import { literal } from '../../pg-format'
25

36
export const USERS_COUNT_ESTIMATE_SQL = `select reltuples as estimate from pg_class where oid = 'auth.users'::regclass`
47

@@ -7,46 +10,74 @@ export const getUsersCountSQL = ({
710
keywords,
811
providers,
912
forceExactCount = false,
13+
column,
1014
}: {
1115
filter?: 'verified' | 'unverified' | 'anonymous'
1216
keywords?: string
1317
providers?: string[]
1418
forceExactCount?: boolean
19+
/** If set, uses optimized prefix search for the specified column */
20+
column?: OptimizedSearchColumns
1521
}) => {
1622
const hasValidKeywords = keywords && keywords !== ''
1723

1824
const conditions: string[] = []
1925
const baseQueryCount = `select count(*) from auth.users`
2026
const baseQuerySelect = `select * from auth.users`
2127

22-
if (hasValidKeywords) {
23-
// [Joshen] Escape single quotes properly
24-
const formattedKeywords = keywords.replaceAll("'", "''")
25-
conditions.push(
26-
`id::text ilike '%${formattedKeywords}%' or email ilike '%${formattedKeywords}%' or phone ilike '%${formattedKeywords}%'`
27-
)
28-
}
29-
30-
if (filter === 'verified') {
31-
conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`)
32-
} else if (filter === 'anonymous') {
33-
conditions.push(`is_anonymous is true`)
34-
} else if (filter === 'unverified') {
35-
conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`)
36-
}
37-
38-
if (providers && providers.length > 0) {
39-
// [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used
40-
// JFYI in case we do eventually run into performance issues here when filtering for SAML provider
41-
if (providers.includes('saml 2.0')) {
28+
const optimizedSearchMode = column && hasValidKeywords
29+
if (optimizedSearchMode) {
30+
if (column === 'email') {
31+
const range = stringRange(keywords)
32+
const lowerBound = literal(range[0])
33+
const upperBound = range[1] ? literal(range[1]) : null
4234
conditions.push(
43-
`(select jsonb_agg(case when value ~ '^sso' then 'sso' else value end) from jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${providers.map((p) => (p === 'saml 2.0' ? `'sso'` : `'${p}'`)).join(', ')}]`.trim()
35+
`lower(email) >= ${lowerBound}${upperBound ? ` and lower(email) < ${upperBound}` : ''} and instance_id = '00000000-0000-0000-0000-000000000000'::uuid`
4436
)
45-
} else {
37+
} else if (column === 'phone') {
38+
const range = stringRange(keywords)
39+
const lowerBound = literal(range[0])
40+
const upperBound = range[1] ? literal(range[1]) : null
41+
conditions.push(`phone >= ${lowerBound}${upperBound ? ` and phone < ${upperBound}` : ''}`)
42+
} else if (column === 'id') {
43+
const lowerUUID = prefixToUUID(keywords, false)
44+
const isMatchingUUIDValue = lowerUUID === keywords
45+
if (isMatchingUUIDValue) {
46+
conditions.push(`id = ${literal(keywords)}`)
47+
} else {
48+
const upperUUID = prefixToUUID(keywords, true)
49+
conditions.push(`id >= ${literal(lowerUUID)} and id < ${literal(upperUUID)}`)
50+
}
51+
}
52+
} else {
53+
// Unified search mode - apply all filters
54+
if (hasValidKeywords) {
55+
const escapedFilterKeywords = literal(`%${keywords}%`)
4656
conditions.push(
47-
`(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]`
57+
`id::text ilike ${escapedFilterKeywords} or email ilike ${escapedFilterKeywords} or phone ilike ${escapedFilterKeywords}`
4858
)
4959
}
60+
61+
if (filter === 'verified') {
62+
conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`)
63+
} else if (filter === 'anonymous') {
64+
conditions.push(`is_anonymous is true`)
65+
} else if (filter === 'unverified') {
66+
conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`)
67+
}
68+
69+
if (providers && providers.length > 0) {
70+
// [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used
71+
// JFYI in case we do eventually run into performance issues here when filtering for SAML provider
72+
if (providers.includes('saml 2.0')) {
73+
const mappedProviders = providers.map((p) => (p === 'saml 2.0' ? 'sso' : p))
74+
conditions.push(
75+
`(select jsonb_agg(case when value ~ '^sso' then 'sso' else value end) from jsonb_array_elements_text((raw_app_meta_data ->> 'providers')::jsonb)) ?| array[${literal(mappedProviders)}]`.trim()
76+
)
77+
} else {
78+
conditions.push(`(raw_app_meta_data->>'providers')::jsonb ?| array[${literal(providers)}]`)
79+
}
80+
}
5081
}
5182

5283
const combinedConditions = conditions.map((x) => `(${x})`).join(' and ')
@@ -58,16 +89,16 @@ export const getUsersCountSQL = ({
5889
const selectBaseSql = `${baseQuerySelect}${whereClause}`
5990
const countBaseSql = `${baseQueryCount}${whereClause}`
6091

61-
const escapedSelectSql = selectBaseSql.replaceAll("'", "''")
92+
const escapedSelectSql = literal(selectBaseSql)
6293

6394
const sql = `
6495
${COUNT_ESTIMATE_SQL}
6596
6697
with approximation as (${USERS_COUNT_ESTIMATE_SQL})
6798
select
6899
case
69-
when estimate = -1 then (select pg_temp.count_estimate('${escapedSelectSql}'))::int
70-
when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate('${escapedSelectSql}'))::int` : 'estimate::int'}
100+
when estimate = -1 then (select pg_temp.count_estimate(${escapedSelectSql}))::int
101+
when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate(${escapedSelectSql}))::int` : 'estimate::int'}
71102
else (${countBaseSql})
72103
end as count,
73104
estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate

0 commit comments

Comments
 (0)