feat(auth): improved user search (#41199)

* feat(auth): improved user search

* chore: fix types for columns

* feat: cursor-based pagination

* chore: remove trigram indexes in favour of b-tree
This commit is contained in:
issuedat authored and GitHub committed 2025-12-24 12:24:43 +01:00
1 parent 621a8e1f34
commit 115ca3a2e3
9 files changed
+415 -185

No files matched your search

@@ -21,6 +21,7 @@ interface SortDropdownProps {
showSortByEmail: boolean
showSortByPhone: boolean
setSortByValue: (value: string) => void
improvedSearchEnabled: boolean
}
export const SortDropdown = ({
@@ -31,8 +32,9 @@ export const SortDropdown = ({
showSortByEmail,
showSortByPhone,
setSortByValue,
improvedSearchEnabled = false,
}: SortDropdownProps) => {
if (specificFilterColumn !== 'freeform') {
if (specificFilterColumn !== 'freeform' && !improvedSearchEnabled) {
return (
<ButtonTooltip
disabled
@@ -4,7 +4,7 @@ import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-use
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
export type SpecificFilterColumn = OptimizedSearchColumns | 'freeform'
export type SpecificFilterColumn = OptimizedSearchColumns | 'name' | 'freeform'
export const UUIDV4_LEFT_PREFIX_REGEX =
/^(?:[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
@@ -149,6 +149,7 @@ export function getDisplayName(user: User, fallback = '-'): string {
last_name,
firstName,
first_name,
name,
} = user.raw_user_meta_data ?? {}
const {
@@ -193,7 +194,8 @@ export function getDisplayName(user: User, fallback = '-'): string {
return (
toPrettyJsonString(
displayName ||
name ||
displayName ||
display_name ||
ccDisplayName ||
cc_display_name ||
@@ -1,6 +1,25 @@
import { Search, X } from 'lucide-react'
import { SetStateAction } from 'react'
import { SpecificFilterColumn } from './Users.constants'
const getSearchPlaceholder = (column: SpecificFilterColumn): string => {
switch (column) {
case 'id':
return 'Search by user ID'
case 'email':
return 'Search by email'
case 'name':
return 'Search by name'
case 'phone':
return 'Search by phone'
case 'freeform':
return 'Search by user ID, email, phone or name'
default:
return 'Search users...'
}
}
import {
Button,
cn,
@@ -20,10 +39,11 @@ import { Input } from 'ui-patterns/DataInputs/Input'
interface UsersSearchProps {
search: string
searchInvalid: boolean
specificFilterColumn: 'id' | 'email' | 'phone' | 'freeform'
specificFilterColumn: SpecificFilterColumn
setSearch: (value: SetStateAction<string>) => void
setFilterKeywords: (value: string) => void
setSpecificFilterColumn: (value: 'id' | 'email' | 'phone' | 'freeform') => void
setSpecificFilterColumn: (value: SpecificFilterColumn) => void
improvedSearchEnabled?: boolean
}
export const UsersSearch = ({
@@ -33,6 +53,7 @@ export const UsersSearch = ({
setSearch,
setFilterKeywords,
setSpecificFilterColumn,
improvedSearchEnabled = false,
}: UsersSearchProps) => {
return (
<div className="flex items-center">
@@ -61,21 +82,30 @@ export const UsersSearch = ({
<SelectItem_Shadcn_ value="email" className="text-xs">
Email address
</SelectItem_Shadcn_>
{improvedSearchEnabled && (
<SelectItem_Shadcn_ value="name" className="text-xs">
Name
</SelectItem_Shadcn_>
)}
<SelectItem_Shadcn_ value="phone" className="text-xs">
Phone number
</SelectItem_Shadcn_>
<SelectSeparator_Shadcn_ />
<Tooltip>
<TooltipTrigger>
<SelectItem_Shadcn_ value="freeform" className="text-xs">
Unified search
</SelectItem_Shadcn_>
</TooltipTrigger>
<TooltipContent side="right" className="w-64 text-center">
Search by all columns at once, including mid-string search. May impact database
performance if you have many users.
</TooltipContent>
</Tooltip>
{!improvedSearchEnabled && (
<>
<SelectSeparator_Shadcn_ />
<Tooltip>
<TooltipTrigger>
<SelectItem_Shadcn_ value="freeform" className="text-xs">
Unified search
</SelectItem_Shadcn_>
</TooltipTrigger>
<TooltipContent side="right" className="w-64 text-center">
Search by all columns at once, including mid-string search. May impact database
performance if you have many users.
</TooltipContent>
</Tooltip>
</>
)}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
@@ -87,11 +117,7 @@ export const UsersSearch = ({
searchInvalid ? 'text-red-900 dark:border-red-900' : '',
search.length > 1 && 'pr-6'
)}
placeholder={
specificFilterColumn === 'freeform'
? 'Search by user ID, email, phone or name'
: `Search by ${specificFilterColumn === 'id' ? 'User ID' : specificFilterColumn === 'email' ? 'Email' : 'Phone'}`
}
placeholder={getSearchPlaceholder(specificFilterColumn)}
value={search}
onChange={(e) => {
const value = e.target.value.replace(/\s+/g, '').toLowerCase()
@@ -99,13 +125,6 @@ export const UsersSearch = ({
}}
onKeyDown={(e) => {
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
setSearch((s) => {
if (s && specificFilterColumn === 'phone' && !s.startsWith('+')) {
return `+${s}`
} else {
return s
}
})
if (!searchInvalid) setFilterKeywords(search.trim().toLocaleLowerCase())
}
}}
@@ -122,16 +122,20 @@ export const UsersV2 = () => {
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState<SpecificFilterColumn>(
'filter',
parseAsStringEnum<SpecificFilterColumn>(['id', 'email', 'phone', 'freeform']).withDefault(
'email'
)
parseAsStringEnum<SpecificFilterColumn>([
'id',
'email',
'phone',
'name',
'freeform',
]).withDefault('email')
)
const [filterUserType, setFilterUserType] = useQueryState(
'userType',
parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
)
const [filterKeywords, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' })
const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'id:asc' })
const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'created_at:desc' })
const [sortColumn, sortOrder] = sortByValue.split(':')
const [selectedColumns, setSelectedColumns] = useQueryState(
'columns',
@@ -149,7 +153,7 @@ export const UsersV2 = () => {
// [Joshen] Opting to store filter column, into local storage for now, which will initialize
// the page when landing on auth users page only if no query params for filter column provided
const [localStorageFilter, setLocalStorageFilter, { isSuccess: isLocalStorageFilterLoaded }] =
useLocalStorageQuery<'id' | 'email' | 'phone' | 'freeform'>(
useLocalStorageQuery<SpecificFilterColumn>(
LOCAL_STORAGE_KEYS.AUTH_USERS_FILTER(projectRef ?? ''),
'email'
)
@@ -198,6 +202,76 @@ export const UsersV2 = () => {
const totalUsers = totalUsersCountData?.count ?? 0
const isCountWithinThresholdForSortBy = totalUsers <= SORT_BY_VALUE_COUNT_THRESHOLD
const isImprovedUserSearchFlagEnabled = useFlag('improvedUserSearch')
const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
const {
data: userSearchIndexes,
isError: isUserSearchIndexesError,
isLoading: isUserSearchIndexesLoading,
} = useUserIndexStatusesQuery({ projectRef, connectionString: project?.connectionString })
const { data: indexWorkerStatus, isLoading: isIndexWorkerStatusLoading } =
useIndexWorkerStatusQuery({
projectRef,
connectionString: project?.connectionString,
})
const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = useAuthConfigUpdateMutation(
{
onSuccess: () => {
toast.success('Initiated creation of user search indexes')
},
onError: (error) => {
toast.error(`Failed to initiate creation of user search indexes: ${error?.message}`)
},
}
)
const handleEnableUserSearchIndexes = () => {
if (!projectRef) return console.error('Project ref is required')
updateAuthConfig({
projectRef: projectRef,
config: { INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST: true },
})
}
const userSearchIndexesAreValidAndReady =
!isUserSearchIndexesError &&
!isUserSearchIndexesLoading &&
userSearchIndexes?.length === pgMeta.USER_SEARCH_INDEXES.length &&
userSearchIndexes?.every((index) => index.is_valid && index.is_ready)
/**
* We want to show the improved search when:
* 1. The feature flag is enabled for them
* 2. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true)
* 3. The required indexes are valid and ready
*/
const improvedSearchEnabled =
isImprovedUserSearchFlagEnabled &&
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true &&
userSearchIndexesAreValidAndReady
/**
* We want to show users the improved search opt-in only if:
* 1. The feature flag is enabled for them
* 2. They have not opted in yet (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is false)
* 3. They have < threshold number of users
*/
const isCountWithinThresholdForOptIn =
isCountLoaded && totalUsers <= IMPROVED_SEARCH_COUNT_THRESHOLD
const showImprovedSearchOptIn =
isImprovedUserSearchFlagEnabled &&
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === false &&
isCountWithinThresholdForOptIn
/**
* We want to show an "in progress" state when:
* 1. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true)
* 2. The index worker is currently in progress
*/
const indexWorkerInProgress =
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true &&
indexWorkerStatus?.is_in_progress === true
const {
data,
error,
@@ -215,21 +289,26 @@ export const UsersV2 = () => {
connectionString: project?.connectionString,
keywords: filterKeywords,
filter:
specificFilterColumn !== 'freeform' || filterUserType === 'all'
(specificFilterColumn !== 'freeform' && !improvedSearchEnabled) || filterUserType === 'all'
? undefined
: filterUserType,
providers: selectedProviders,
sort: sortColumn as 'id' | 'created_at' | 'email' | 'phone',
order: sortOrder as 'asc' | 'desc',
...(specificFilterColumn !== 'freeform'
// improved search will always have a column specified
...(specificFilterColumn !== 'freeform' || improvedSearchEnabled
? { column: specificFilterColumn as OptimizedSearchColumns }
: { column: undefined }),
improvedSearchEnabled: improvedSearchEnabled,
},
{
placeholderData: Boolean(filterKeywords) ? keepPreviousData : undefined,
// [Joshen] This is to prevent the dashboard from invalidating when refocusing as it may create
// a barrage of requests to invalidate each page esp when the project has many many users.
staleTime: Infinity,
// NOTE(iat): query the user data only after we know whether to show improved search or not
enabled: !isUserSearchIndexesLoading && !isAuthConfigLoading && !isIndexWorkerStatusLoading,
}
)
@@ -242,7 +321,10 @@ export const UsersV2 = () => {
const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0])
const searchInvalid =
!search || specificFilterColumn === 'freeform' || specificFilterColumn === 'email'
!search ||
specificFilterColumn === 'freeform' ||
specificFilterColumn === 'email' ||
specificFilterColumn === 'name'
? false
: specificFilterColumn === 'id'
? !search.match(UUIDV4_LEFT_PREFIX_REGEX)
@@ -261,10 +343,10 @@ export const UsersV2 = () => {
organization: selectedOrg?.slug ?? 'Unknown',
}
const updateStorageFilter = (value: 'id' | 'email' | 'phone' | 'freeform') => {
const updateStorageFilter = (value: SpecificFilterColumn) => {
setLocalStorageFilter(value)
setSpecificFilterColumn(value)
if (value !== 'freeform') {
if (value !== 'freeform' && !improvedSearchEnabled) {
updateSortByValue('id:asc')
}
}
@@ -350,74 +432,6 @@ export const UsersV2 = () => {
}
}
const isImprovedUserSearchEnabled = useFlag('improvedUserSearch')
const { data: authConfig } = useAuthConfigQuery({ projectRef })
const {
data: userSearchIndexes,
isError: isUserSearchIndexesError,
isLoading: isUserSearchIndexesLoading,
} = useUserIndexStatusesQuery({ projectRef, connectionString: project?.connectionString })
const { data: indexWorkerStatus } = useIndexWorkerStatusQuery({
projectRef,
connectionString: project?.connectionString,
})
const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = useAuthConfigUpdateMutation(
{
onSuccess: () => {
toast.success('Initiated creation of user search indexes')
},
onError: (error) => {
toast.error(`Failed to initiate creation of user search indexes: ${error?.message}`)
},
}
)
const handleEnableUserSearchIndexes = () => {
if (!projectRef) return console.error('Project ref is required')
updateAuthConfig({
projectRef: projectRef,
config: { INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST: true },
})
}
const userSearchIndexesAreValidAndReady =
!isUserSearchIndexesError &&
!isUserSearchIndexesLoading &&
userSearchIndexes?.length === pgMeta.USER_SEARCH_INDEXES.length &&
userSearchIndexes?.every((index) => index.is_valid && index.is_ready)
/**
* We want to show the improved search when:
* 1. The feature flag is enabled for them
* 2. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true)
* 3. The required indexes are valid and ready
*/
const _showImprovedSearch =
isImprovedUserSearchEnabled &&
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true &&
userSearchIndexesAreValidAndReady
/**
* We want to show users the improved search opt-in only if:
* 1. The feature flag is enabled for them
* 2. They have not opted in yet (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is false)
* 3. They have < threshold number of users
*/
const isCountWithinThresholdForOptIn = totalUsers <= IMPROVED_SEARCH_COUNT_THRESHOLD
const showImprovedSearchOptIn =
isImprovedUserSearchEnabled &&
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === false &&
isCountWithinThresholdForOptIn
/**
* We want to show an "in progress" state when:
* 1. The user has opted in (authConfig.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST is true)
* 2. The index worker is currently in progress
*/
const indexWorkerInProgress =
authConfig?.INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST === true &&
indexWorkerStatus?.is_in_progress === true
useEffect(() => {
if (
!isRefetching &&
@@ -562,77 +576,80 @@ export const UsersV2 = () => {
updateStorageFilter(value)
}
}}
improvedSearchEnabled={improvedSearchEnabled}
/>
{showUserTypeFilter && specificFilterColumn === 'freeform' && (
<Select_Shadcn_
value={filterUserType}
onValueChange={(val) => {
setFilterUserType(val as Filter)
sendEvent({
action: 'auth_users_search_submitted',
properties: {
trigger: 'user_type_filter',
...telemetryProps,
user_type: val,
},
groups: telemetryGroups,
})
}}
>
<SelectTrigger_Shadcn_
size="tiny"
className={cn(
'w-[140px] !bg-transparent',
filterUserType === 'all' && 'border-dashed'
)}
{showUserTypeFilter &&
(specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
<Select_Shadcn_
value={filterUserType}
onValueChange={(val) => {
setFilterUserType(val as Filter)
sendEvent({
action: 'auth_users_search_submitted',
properties: {
trigger: 'user_type_filter',
...telemetryProps,
user_type: val,
},
groups: telemetryGroups,
})
}}
>
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="all" className="text-xs">
All users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="verified" className="text-xs">
Verified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="unverified" className="text-xs">
Unverified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
Anonymous users
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
)}
<SelectTrigger_Shadcn_
size="tiny"
className={cn(
'w-[140px] !bg-transparent',
filterUserType === 'all' && 'border-dashed'
)}
>
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="all" className="text-xs">
All users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="verified" className="text-xs">
Verified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="unverified" className="text-xs">
Unverified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
Anonymous users
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
)}
{showProviderFilter && specificFilterColumn === 'freeform' && (
<FilterPopover
name="Provider"
options={PROVIDER_FILTER_OPTIONS}
labelKey="name"
valueKey="value"
iconKey="icon"
activeOptions={selectedProviders}
labelClass="text-xs"
maxHeightClass="h-[190px]"
className="w-52"
onSaveFilters={(providers) => {
setSelectedProviders(providers)
sendEvent({
action: 'auth_users_search_submitted',
properties: {
trigger: 'provider_filter',
...telemetryProps,
providers,
},
groups: telemetryGroups,
})
}}
/>
)}
{showProviderFilter &&
(specificFilterColumn === 'freeform' || improvedSearchEnabled) && (
<FilterPopover
name="Provider"
options={PROVIDER_FILTER_OPTIONS}
labelKey="name"
valueKey="value"
iconKey="icon"
activeOptions={selectedProviders}
labelClass="text-xs"
maxHeightClass="h-[190px]"
className="w-52"
onSaveFilters={(providers) => {
setSelectedProviders(providers)
sendEvent({
action: 'auth_users_search_submitted',
properties: {
trigger: 'provider_filter',
...telemetryProps,
providers,
},
groups: telemetryGroups,
})
}}
/>
)}
<div className="border-r border-strong h-6" />
@@ -704,6 +721,7 @@ export const UsersV2 = () => {
}}
showSortByEmail={showSortByEmail}
showSortByPhone={showSortByPhone}
improvedSearchEnabled={improvedSearchEnabled}
/>
</div>
+25 -8
View File
@@ -1,4 +1,7 @@
import { getPaginatedUsersSQL } from '@supabase/pg-meta/src/sql/studio/get-users-paginated'
import {
getPaginatedUsersSQL,
UsersCursor,
} from '@supabase/pg-meta/src/sql/studio/get-users-paginated'
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
@@ -24,6 +27,8 @@ type UsersVariables = {
/** If set, uses optimized prefix search for the specified column */
column?: OptimizedSearchColumns
startAt?: string
improvedSearchEnabled?: boolean
}
export type Filter = 'verified' | 'unverified' | 'anonymous'
@@ -39,6 +44,8 @@ export const useUsersInfiniteQuery = <TData = UsersData>(
sort,
order,
column,
improvedSearchEnabled = false,
}: UsersVariables,
{
enabled = true,
@@ -48,7 +55,7 @@ export const useUsersInfiniteQuery = <TData = UsersData>(
UsersError,
InfiniteData<TData>,
readonly unknown[],
string | number | undefined
string | number | UsersCursor | undefined
> = {}
) => {
const { data: project } = useSelectedProjectQuery()
@@ -78,6 +85,8 @@ export const useUsersInfiniteQuery = <TData = UsersData>(
limit: USERS_PAGE_LIMIT,
column,
startAt: column ? (pageParam as string) : undefined,
cursor: improvedSearchEnabled ? (pageParam as UsersCursor) : undefined,
improvedSearchEnabled,
}),
},
signal
@@ -87,14 +96,22 @@ export const useUsersInfiniteQuery = <TData = UsersData>(
initialPageParam: undefined,
getNextPageParam(lastPage, pages) {
const hasNextPage = lastPage.result.length >= USERS_PAGE_LIMIT
if (!hasNextPage) return undefined
const lastItem = lastPage.result[lastPage.result.length - 1]
if (!lastItem) return undefined
// for improved search, we always use cursor-based pagination where the ORDER BY
// clause is the specified sort column + the id column as a tie breaker
if (improvedSearchEnabled) {
const sortColumn = sort ?? 'created_at'
return { sort: lastItem[sortColumn], id: lastItem.id } as UsersCursor
}
if (column) {
const lastItem = lastPage.result[lastPage.result.length - 1]
if (hasNextPage && lastItem) return lastItem[column]
return undefined
return lastItem[column as Exclude<OptimizedSearchColumns, 'name'>]
} else {
const page = pages.length
if (!hasNextPage) return undefined
return page
return pages.length
}
},
...options,
@@ -2,10 +2,9 @@ import { literal } from '../../pg-format'
export const USER_SEARCH_INDEXES = [
'idx_users_email',
'idx_users_email_trgm',
'idx_users_created_at_desc',
'idx_users_last_sign_in_at_desc',
'idx_users_name_trgm',
'idx_users_name',
// this index is not created by the indexworker but is required for efficient queries
// it is already created as part of the `UNIQUE` constraint on the `phone` column
'users_phone_key',
@@ -1,6 +1,11 @@
import { prefixToUUID, stringRange } from './get-users-common'
import { OptimizedSearchColumns } from './get-users-types'
export interface UsersCursor {
sort: string
id: string
}
interface getPaginatedUsersSQLProps {
page?: number
verified?: 'verified' | 'unverified' | 'anonymous'
@@ -13,6 +18,10 @@ interface getPaginatedUsersSQLProps {
/** If set, uses fast queries but these don't allow any sorting so the above parameters are completely ignored. */
column?: OptimizedSearchColumns
startAt?: string
/** Cursor for cursor-based pagination (used by improved search) */
cursor?: UsersCursor
improvedSearchEnabled?: boolean
}
const DEFAULT_LIMIT = 50
@@ -28,7 +37,23 @@ export const getPaginatedUsersSQL = ({
column,
startAt,
cursor,
improvedSearchEnabled = false,
}: getPaginatedUsersSQLProps) => {
if (improvedSearchEnabled) {
return getImprovedPaginatedUsersSQL({
column: column ?? 'email',
keywords,
verified,
providers,
sort,
order,
limit,
cursor,
})
}
// IMPORTANT: DO NOT CHANGE THESE QUERIES EVEN IN THE SLIGHTEST WITHOUT CONSULTING WITH AUTH TEAM.
const offset = page * limit
const hasValidKeywords = keywords && keywords !== ''
@@ -139,3 +164,151 @@ from
return usersQuery
}
/**
* Generates SQL for improved paginated user search that leverages specific indexes.
* Uses cursor-based pagination for efficient and consistent paging.
*
* Indexes leveraged:
* - idx_users_email (btree) - for email prefix and exact match searches and sorting by email
* - idx_users_created_at_desc - for sorting by created_at
* - idx_users_last_sign_in_at_desc - for sorting by last_sign_in_at
* - idx_users_name (btree) - for name prefix and exact match searches on raw_user_meta_data->>'name'
* - users_phone_key (btree) - for phone prefix searches and sorting by phone
*/
export const getImprovedPaginatedUsersSQL = ({
column,
keywords,
verified,
providers,
sort,
order,
cursor,
limit = DEFAULT_LIMIT,
}: getPaginatedUsersSQLProps) => {
const hasValidKeywords = keywords && keywords !== ''
const formattedKeywords = hasValidKeywords ? keywords.replaceAll("'", "''") : ''
const conditions: string[] = []
// Column-specific search condition
if (hasValidKeywords) {
if (column === 'email') {
// Use btree index with prefix matching
const range = stringRange(formattedKeywords)
if (range[1]) {
conditions.push(`email >= '${range[0]}' AND email < '${range[1]}'`)
} else {
conditions.push(`email >= '${range[0]}'`)
}
} else if (column === 'phone') {
// Use btree index with prefix matching
const range = stringRange(formattedKeywords)
if (range[1]) {
conditions.push(`phone >= '${range[0]}' AND phone < '${range[1]}'`)
} else {
conditions.push(`phone >= '${range[0]}'`)
}
} else if (column === 'id') {
// Exact match on UUID
conditions.push(`id = '${formattedKeywords}'`)
} else if (column === 'name') {
// Use btree index with prefix matching on raw_user_meta_data->>'name'
const range = stringRange(formattedKeywords)
if (range[1]) {
conditions.push(
`raw_user_meta_data->>'name' >= '${range[0]}' AND raw_user_meta_data->>'name' < '${range[1]}'`
)
} else {
conditions.push(`raw_user_meta_data->>'name' >= '${range[0]}'`)
}
}
}
// Verified filter
if (verified === 'verified') {
conditions.push(`(email_confirmed_at IS NOT NULL OR phone_confirmed_at IS NOT NULL)`)
} else if (verified === 'anonymous') {
conditions.push(`is_anonymous IS TRUE`)
} else if (verified === 'unverified') {
conditions.push(`(email_confirmed_at IS NULL AND phone_confirmed_at IS NULL)`)
}
// Providers filter
if (providers && providers.length > 0) {
if (providers.includes('saml 2.0')) {
conditions.push(
`(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(', ')}]`
)
} else {
conditions.push(
`(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]`
)
}
}
const sortOn = sort ?? 'created_at'
const sortOrder = order ?? 'desc'
// Cursor-based pagination: fetch rows after the cursor position
if (cursor) {
const operator = sortOrder === 'desc' ? '<' : '>'
// When sorting by id, no need for a composite cursor since id is already unique
if (sortOn === 'id') {
conditions.push(`id ${operator} '${cursor.id}'::uuid`)
} else {
conditions.push(`("${sortOn}", id) ${operator} ('${cursor.sort}', '${cursor.id}'::uuid)`)
}
}
const combinedConditions = conditions.map((x) => `(${x})`).join(' AND ')
const whereClause = conditions.length > 0 ? `WHERE ${combinedConditions}` : ''
// Order by sort column, with id as tie breaker (unless already sorting by id)
const orderByClause =
sortOn === 'id' ? `"${sortOn}" ${sortOrder}` : `"${sortOn}" ${sortOrder}, id ${sortOrder}`
const usersData = `
SELECT
auth.users.id,
auth.users.email,
auth.users.banned_until,
auth.users.created_at,
auth.users.confirmed_at,
auth.users.confirmation_sent_at,
auth.users.is_anonymous,
auth.users.is_sso_user,
auth.users.invited_at,
auth.users.last_sign_in_at,
auth.users.phone,
auth.users.raw_app_meta_data,
auth.users.raw_user_meta_data
FROM
auth.users
${whereClause}
ORDER BY
${orderByClause}
LIMIT
${limit}`
const usersQuery = `
WITH
users_data AS (${usersData})
SELECT
*,
COALESCE(
(
SELECT
array_agg(DISTINCT i.provider)
FROM
auth.identities i
WHERE
i.user_id = users_data.id
),
'{}'::text[]
) AS providers
FROM
users_data;`.trim()
return usersQuery
}
@@ -1 +1 @@
export type OptimizedSearchColumns = 'id' | 'email' | 'phone'
export type OptimizedSearchColumns = 'id' | 'email' | 'phone' | 'name'