diff --git a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx index eee1b8ed158..654c58198a3 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx @@ -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 } ) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index b1b1b9636ee..718e8a3878e 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -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( 'filter', - parseAsStringEnum(['id', 'email', 'phone', 'freeform']).withDefault('id') + parseAsStringEnum(['id', 'email', 'phone', 'freeform']).withDefault('id') ) const [filterUserType, setFilterUserType] = useQueryState( 'userType', diff --git a/apps/studio/data/auth/keys.ts b/apps/studio/data/auth/keys.ts index 86fa936c13b..02c9fe9084d 100644 --- a/apps/studio/data/auth/keys.ts +++ b/apps/studio/data/auth/keys.ts @@ -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, diff --git a/apps/studio/data/auth/users-count-query.ts b/apps/studio/data/auth/users-count-query.ts index 434d45a13a0..7d78176ad4f 100644 --- a/apps/studio/data/auth/users-count-query.ts +++ b/apps/studio/data/auth/users-count-query.ts @@ -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 = ( filter, providers, forceExactCount, + column, }: UsersCountVariables, { enabled = true, ...options }: UseCustomQueryOptions = {} ) => @@ -72,6 +78,7 @@ export const useUsersCountQuery = ( filter, providers, forceExactCount, + column, }), queryFn: ({ signal }) => getUsersCount( @@ -82,6 +89,7 @@ export const useUsersCountQuery = ( filter, providers, forceExactCount, + column, }, signal ), diff --git a/packages/pg-meta/src/sql/studio/get-users-common.ts b/packages/pg-meta/src/sql/studio/get-users-common.ts new file mode 100644 index 00000000000..9a83845b89c --- /dev/null +++ b/packages/pg-meta/src/sql/studio/get-users-common.ts @@ -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)] +} diff --git a/packages/pg-meta/src/sql/studio/get-users-count.ts b/packages/pg-meta/src/sql/studio/get-users-count.ts index 1d1f5e6145d..fc7f90c91f1 100644 --- a/packages/pg-meta/src/sql/studio/get-users-count.ts +++ b/packages/pg-meta/src/sql/studio/get-users-count.ts @@ -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(', ')}]` + ) + } } } diff --git a/packages/pg-meta/src/sql/studio/get-users-paginated.ts b/packages/pg-meta/src/sql/studio/get-users-paginated.ts index edd2b9e821e..43b2a62aa4c 100644 --- a/packages/pg-meta/src/sql/studio/get-users-paginated.ts +++ b/packages/pg-meta/src/sql/studio/get-users-paginated.ts @@ -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, diff --git a/packages/pg-meta/src/sql/studio/get-users-types.ts b/packages/pg-meta/src/sql/studio/get-users-types.ts new file mode 100644 index 00000000000..d0abed10de2 --- /dev/null +++ b/packages/pg-meta/src/sql/studio/get-users-types.ts @@ -0,0 +1 @@ +export type OptimizedSearchColumns = 'id' | 'email' | 'phone'