mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 12:55:06 +03:00
Updated to use optimized search columns when getting user counts
This commit is contained in:
1 parent
3ebc95e417
commit
9eb549e34e
8 files changed
+124
-71
No files matched your search
@@ -9,6 +9,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { Filter } from './Users.constants'
|
||||
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
|
||||
interface UsersFooterProps {
|
||||
filter: Filter
|
||||
@@ -42,6 +43,13 @@ export const UsersFooter = ({
|
||||
filter: filter === 'all' ? undefined : filter,
|
||||
providers: selectedProviders,
|
||||
forceExactCount,
|
||||
// Force exact count for optimized search since result sets are small
|
||||
// forceExactCount: forceExactCount || specificFilterColumn !== 'freeform',
|
||||
|
||||
// Use optimized search when filtering by specific column
|
||||
...(specificFilterColumn !== 'freeform'
|
||||
? { column: specificFilterColumn as OptimizedSearchColumns }
|
||||
: { column: undefined }),
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
import { formatUserColumns, formatUsersData } from './Users.utils'
|
||||
import { UsersFooter } from './UsersFooter'
|
||||
import { UsersSearch } from './UsersSearch'
|
||||
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
|
||||
type SpecificFilterColumn = OptimizedSearchColumns | 'freeform'
|
||||
|
||||
const SORT_BY_VALUE_COUNT_THRESHOLD = 10_000
|
||||
|
||||
@@ -91,9 +94,9 @@ export const UsersV2 = () => {
|
||||
}
|
||||
}, [showEmailPhoneColumns])
|
||||
|
||||
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState(
|
||||
const [specificFilterColumn, setSpecificFilterColumn] = useQueryState<SpecificFilterColumn>(
|
||||
'filter',
|
||||
parseAsStringEnum(['id', 'email', 'phone', 'freeform']).withDefault('id')
|
||||
parseAsStringEnum<SpecificFilterColumn>(['id', 'email', 'phone', 'freeform']).withDefault('id')
|
||||
)
|
||||
const [filterUserType, setFilterUserType] = useQueryState(
|
||||
'userType',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
|
||||
export const authKeys = {
|
||||
users: (
|
||||
projectRef: string | undefined,
|
||||
@@ -39,6 +41,7 @@ export const authKeys = {
|
||||
filter: string | undefined
|
||||
providers: string[] | undefined
|
||||
forceExactCount?: boolean
|
||||
column?: OptimizedSearchColumns
|
||||
}
|
||||
) =>
|
||||
['projects', projectRef, 'users-count', ...(params ? [params].filter(Boolean) : [])] as const,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
import { getUsersCountSQL } from '@supabase/pg-meta/src/sql/studio/get-users-count'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
@@ -13,6 +14,9 @@ type UsersCountVariables = {
|
||||
filter?: Filter
|
||||
providers?: string[]
|
||||
forceExactCount?: boolean
|
||||
|
||||
/** If set, uses optimized prefix search for the specified column */
|
||||
column?: OptimizedSearchColumns
|
||||
}
|
||||
|
||||
export async function getUsersCount(
|
||||
@@ -23,10 +27,11 @@ export async function getUsersCount(
|
||||
filter,
|
||||
providers,
|
||||
forceExactCount,
|
||||
column,
|
||||
}: UsersCountVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const sql = getUsersCountSQL({ filter, keywords, providers, forceExactCount })
|
||||
const sql = getUsersCountSQL({ filter, keywords, providers, forceExactCount, column })
|
||||
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
@@ -63,6 +68,7 @@ export const useUsersCountQuery = <TData = UsersCountData>(
|
||||
filter,
|
||||
providers,
|
||||
forceExactCount,
|
||||
column,
|
||||
}: UsersCountVariables,
|
||||
{ enabled = true, ...options }: UseCustomQueryOptions<UsersCountData, UsersCountError, TData> = {}
|
||||
) =>
|
||||
@@ -72,6 +78,7 @@ export const useUsersCountQuery = <TData = UsersCountData>(
|
||||
filter,
|
||||
providers,
|
||||
forceExactCount,
|
||||
column,
|
||||
}),
|
||||
queryFn: ({ signal }) =>
|
||||
getUsersCount(
|
||||
@@ -82,6 +89,7 @@ export const useUsersCountQuery = <TData = UsersCountData>(
|
||||
filter,
|
||||
providers,
|
||||
forceExactCount,
|
||||
column,
|
||||
},
|
||||
signal
|
||||
),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export function prefixToUUID(prefix: string, max: boolean) {
|
||||
const mapped = '00000000-0000-0000-0000-000000000000'
|
||||
.split('')
|
||||
.map((c, i) => (c === '-' ? c : prefix[i] ?? c))
|
||||
|
||||
if (prefix.length >= mapped.length) {
|
||||
return mapped.join('')
|
||||
}
|
||||
|
||||
if (prefix.length && prefix.length < 15) {
|
||||
mapped[14] = '4'
|
||||
}
|
||||
|
||||
if (prefix.length && prefix.length < 20) {
|
||||
mapped[19] = max ? 'b' : '8'
|
||||
}
|
||||
|
||||
if (max) {
|
||||
for (let i = prefix.length; i < mapped.length; i += 1) {
|
||||
if (mapped[i] === '0') {
|
||||
mapped[i] = 'f'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped.join('')
|
||||
}
|
||||
|
||||
export function stringRange(prefix: string) {
|
||||
if (!prefix) {
|
||||
return [prefix, undefined]
|
||||
}
|
||||
|
||||
const lastChar = prefix.charCodeAt(prefix.length - 1)
|
||||
|
||||
if (lastChar >= `~`.charCodeAt(0)) {
|
||||
// not ASCII
|
||||
return [prefix, prefix]
|
||||
}
|
||||
|
||||
return [prefix, prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastChar + 1)]
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { OptimizedSearchColumns } from './get-users-types'
|
||||
import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from './get-count-estimate'
|
||||
import { stringRange, prefixToUUID } from './get-users-common'
|
||||
|
||||
export const USERS_COUNT_ESTIMATE_SQL = `select reltuples as estimate from pg_class where oid = 'auth.users'::regclass`
|
||||
|
||||
@@ -7,11 +9,14 @@ export const getUsersCountSQL = ({
|
||||
keywords,
|
||||
providers,
|
||||
forceExactCount = false,
|
||||
column,
|
||||
}: {
|
||||
filter?: 'verified' | 'unverified' | 'anonymous'
|
||||
keywords?: string
|
||||
providers?: string[]
|
||||
forceExactCount?: boolean
|
||||
/** If set, uses optimized prefix search for the specified column */
|
||||
column?: OptimizedSearchColumns
|
||||
}) => {
|
||||
const hasValidKeywords = keywords && keywords !== ''
|
||||
|
||||
@@ -19,33 +24,56 @@ export const getUsersCountSQL = ({
|
||||
const baseQueryCount = `select count(*) from auth.users`
|
||||
const baseQuerySelect = `select * from auth.users`
|
||||
|
||||
if (hasValidKeywords) {
|
||||
// [Joshen] Escape single quotes properly
|
||||
const optimizedSearchMode = column && hasValidKeywords
|
||||
if (optimizedSearchMode) {
|
||||
const formattedKeywords = keywords.replaceAll("'", "''")
|
||||
conditions.push(
|
||||
`id::text ilike '%${formattedKeywords}%' or email ilike '%${formattedKeywords}%' or phone ilike '%${formattedKeywords}%'`
|
||||
)
|
||||
}
|
||||
|
||||
if (filter === 'verified') {
|
||||
conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`)
|
||||
} else if (filter === 'anonymous') {
|
||||
conditions.push(`is_anonymous is true`)
|
||||
} else if (filter === 'unverified') {
|
||||
conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`)
|
||||
}
|
||||
if (column === 'email') {
|
||||
const range = stringRange(formattedKeywords)
|
||||
conditions.push(
|
||||
`lower(email) >= '${range[0]}'${range[1] ? ` and lower(email) < '${range[1]}'` : ''} and instance_id = '00000000-0000-0000-0000-000000000000'::uuid`
|
||||
)
|
||||
} else if (column === 'phone') {
|
||||
const range = stringRange(formattedKeywords)
|
||||
conditions.push(`phone >= '${range[0]}'${range[1] ? ` and phone < '${range[1]}'` : ''}`)
|
||||
} else if (column === 'id') {
|
||||
const isMatchingUUIDValue = prefixToUUID(formattedKeywords, false) === formattedKeywords
|
||||
conditions.push(
|
||||
isMatchingUUIDValue
|
||||
? `id = '${formattedKeywords}'`
|
||||
: `id >= '${prefixToUUID(formattedKeywords, false)}' and id < '${prefixToUUID(formattedKeywords, true)}'`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Unified search mode - apply all filters
|
||||
if (hasValidKeywords) {
|
||||
// [Joshen] Escape single quotes properly
|
||||
const formattedKeywords = keywords.replaceAll("'", "''")
|
||||
conditions.push(
|
||||
`id::text ilike '%${formattedKeywords}%' or email ilike '%${formattedKeywords}%' or phone ilike '%${formattedKeywords}%'`
|
||||
)
|
||||
}
|
||||
|
||||
if (providers && providers.length > 0) {
|
||||
// [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used
|
||||
// JFYI in case we do eventually run into performance issues here when filtering for SAML provider
|
||||
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(', ')}]`.trim()
|
||||
)
|
||||
} else {
|
||||
conditions.push(
|
||||
`(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]`
|
||||
)
|
||||
if (filter === 'verified') {
|
||||
conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`)
|
||||
} else if (filter === 'anonymous') {
|
||||
conditions.push(`is_anonymous is true`)
|
||||
} else if (filter === 'unverified') {
|
||||
conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`)
|
||||
}
|
||||
|
||||
if (providers && providers.length > 0) {
|
||||
// [Joshen] This is arguarbly not fully optimized, but at the same time not commonly used
|
||||
// JFYI in case we do eventually run into performance issues here when filtering for SAML provider
|
||||
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(', ')}]`.trim()
|
||||
)
|
||||
} else {
|
||||
conditions.push(
|
||||
`(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { stringRange, prefixToUUID } from './get-users-common'
|
||||
import { OptimizedSearchColumns } from './get-users-types'
|
||||
|
||||
interface getPaginatedUsersSQLProps {
|
||||
page?: number
|
||||
verified?: 'verified' | 'unverified' | 'anonymous'
|
||||
@@ -8,55 +11,12 @@ interface getPaginatedUsersSQLProps {
|
||||
limit?: number
|
||||
|
||||
/** If set, uses fast queries but these don't allow any sorting so the above parameters are completely ignored. */
|
||||
column?: 'id' | 'email' | 'phone'
|
||||
column?: OptimizedSearchColumns
|
||||
startAt?: string
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 50
|
||||
|
||||
function prefixToUUID(prefix: string, max: boolean) {
|
||||
const mapped = '00000000-0000-0000-0000-000000000000'
|
||||
.split('')
|
||||
.map((c, i) => (c === '-' ? c : prefix[i] ?? c))
|
||||
|
||||
if (prefix.length >= mapped.length) {
|
||||
return mapped.join('')
|
||||
}
|
||||
|
||||
if (prefix.length && prefix.length < 15) {
|
||||
mapped[14] = '4'
|
||||
}
|
||||
|
||||
if (prefix.length && prefix.length < 20) {
|
||||
mapped[19] = max ? 'b' : '8'
|
||||
}
|
||||
|
||||
if (max) {
|
||||
for (let i = prefix.length; i < mapped.length; i += 1) {
|
||||
if (mapped[i] === '0') {
|
||||
mapped[i] = 'f'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped.join('')
|
||||
}
|
||||
|
||||
function stringRange(prefix: string) {
|
||||
if (!prefix) {
|
||||
return [prefix, undefined]
|
||||
}
|
||||
|
||||
const lastChar = prefix.charCodeAt(prefix.length - 1)
|
||||
|
||||
if (lastChar >= `~`.charCodeAt(0)) {
|
||||
// not ASCII
|
||||
return [prefix, prefix]
|
||||
}
|
||||
|
||||
return [prefix, prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastChar + 1)]
|
||||
}
|
||||
|
||||
export const getPaginatedUsersSQL = ({
|
||||
page = 0,
|
||||
verified,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type OptimizedSearchColumns = 'id' | 'email' | 'phone'
|
||||
Reference in new issue
Block a user