+ {
+ setSpecificFilterColumn('freeform')
+ setShowFreeformWarning(false)
+ }}
+ onCancel={() => setShowFreeformWarning(false)}
+ alert={{
+ base: { variant: 'warning' },
+ title: 'Searching across all columns is not recommended',
+ description:
+ 'This may adversely impact your database, in particular if your project has a large number of users - use with caution.',
+ }}
+ >
+
+ This will allow you to search across user ID, email, phone number, and display name
+ through a single input field. You will also be able to filter users by provider and sort
+ on users across different columns.
+
+
+
{/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */}
{
cleanPointerEventsNoneOnBody(500)
}}
/>
-
- setShowFetchExactCountModal(false)}
- onConfirm={() => {
- setForceExactCount(true)
- setShowFetchExactCountModal(false)
- }}
- >
-
- Your project has more than {THRESHOLD_COUNT.toLocaleString()} users, and fetching the
- exact count may cause performance issues on your database.
-
-
>
)
}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx
index ffd7e5ba2b360..1cea7a375c84b 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx
@@ -40,7 +40,7 @@ export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archi
const { data: organizations } = useOrganizationsQuery()
const { data } = useProjectsInfiniteQuery(
{ search: search.length === 0 ? search : debouncedSearch },
- { keepPreviousData: true }
+ { keepPreviousData: true, enabled: open }
)
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
const projectCount = data?.pages[0].pagination.count ?? 0
diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx
index f1a12adb50c09..678f11bfdc428 100644
--- a/apps/studio/components/ui/OrganizationProjectSelector.tsx
+++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx
@@ -86,7 +86,7 @@ export const OrganizationProjectSelector = ({
fetchNextPage,
} = useOrgProjectsInfiniteQuery(
{ slug, search: search.length === 0 ? search : debouncedSearch },
- { keepPreviousData: true }
+ { enabled: open, keepPreviousData: true }
)
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
diff --git a/apps/studio/data/auth/keys.ts b/apps/studio/data/auth/keys.ts
index de6e3852ea7ee..86fa936c13b8d 100644
--- a/apps/studio/data/auth/keys.ts
+++ b/apps/studio/data/auth/keys.ts
@@ -8,6 +8,14 @@ export const authKeys = {
}
) => ['projects', projectRef, 'users', ...(params ? [params] : [])] as const,
+ usersQuery: (
+ projectRef: string | undefined,
+ params?: {
+ query: string
+ startAt: string
+ }
+ ) => ['projects', projectRef, 'users-query', ...(params ? [params] : [])] as const,
+
usersInfinite: (
projectRef: string | undefined,
params?: {
diff --git a/apps/studio/data/auth/user-create-mutation.ts b/apps/studio/data/auth/user-create-mutation.ts
index 4ff0ea43f4da5..533c8e2df418b 100644
--- a/apps/studio/data/auth/user-create-mutation.ts
+++ b/apps/studio/data/auth/user-create-mutation.ts
@@ -45,10 +45,7 @@ export const useUserCreateMutation = ({
async onSuccess(data, variables, context) {
const { projectRef } = variables
- await Promise.all([
- queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
- queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
- ])
+ await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))])
await onSuccess?.(data, variables, context)
},
diff --git a/apps/studio/data/auth/user-delete-mutation.ts b/apps/studio/data/auth/user-delete-mutation.ts
index 819235982d662..0b67188f05238 100644
--- a/apps/studio/data/auth/user-delete-mutation.ts
+++ b/apps/studio/data/auth/user-delete-mutation.ts
@@ -38,10 +38,7 @@ export const useUserDeleteMutation = ({
const { projectRef, skipInvalidation = false } = variables
if (!skipInvalidation) {
- await Promise.all([
- queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
- queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
- ])
+ await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))])
}
await onSuccess?.(data, variables, context)
diff --git a/apps/studio/data/auth/user-invite-mutation.ts b/apps/studio/data/auth/user-invite-mutation.ts
index 0466ab0ed0944..42bff571c6d76 100644
--- a/apps/studio/data/auth/user-invite-mutation.ts
+++ b/apps/studio/data/auth/user-invite-mutation.ts
@@ -37,10 +37,7 @@ export const useUserInviteMutation = ({
async onSuccess(data, variables, context) {
const { projectRef } = variables
- await Promise.all([
- queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
- queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
- ])
+ await Promise.all([queryClient.invalidateQueries(authKeys.usersInfinite(projectRef))])
await onSuccess?.(data, variables, context)
},
diff --git a/apps/studio/data/auth/users-count-query.ts b/apps/studio/data/auth/users-count-query.ts
index 0f0c7371ec6f8..9c8730eff1013 100644
--- a/apps/studio/data/auth/users-count-query.ts
+++ b/apps/studio/data/auth/users-count-query.ts
@@ -1,7 +1,7 @@
+import { getUsersCountSQL } from '@supabase/pg-meta/src/sql/studio/get-users-count'
import { useQuery, type UseQueryOptions } from '@tanstack/react-query'
import { executeSql, type ExecuteSqlError } from 'data/sql/execute-sql-query'
-import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query'
import { authKeys } from './keys'
import { type Filter } from './users-infinite-query'
@@ -14,86 +14,6 @@ type UsersCountVariables = {
forceExactCount?: boolean
}
-const getUsersCountSql = ({
- filter,
- keywords,
- providers,
- forceExactCount = false,
-}: {
- filter?: Filter
- keywords?: string
- providers?: string[]
- forceExactCount?: boolean
-}) => {
- const hasValidKeywords = keywords && keywords !== ''
-
- const conditions: string[] = []
- const baseQueryCount = `select count(*) from auth.users`
- const baseQuerySelect = `select * from auth.users`
-
- 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 (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(', ')}]`
- )
- }
- }
-
- const combinedConditions = conditions.map((x) => `(${x})`).join(' and ')
- const whereClause = conditions.length > 0 ? ` where ${combinedConditions}` : ''
-
- if (forceExactCount) {
- return `select (${baseQueryCount}${whereClause}), false as is_estimate;`
- } else {
- const selectBaseSql = `${baseQuerySelect}${whereClause}`
- const countBaseSql = `${baseQueryCount}${whereClause}`
-
- const escapedSelectSql = selectBaseSql.replaceAll("'", "''")
-
- const sql = `
-${COUNT_ESTIMATE_SQL}
-
-with approximation as (
- select reltuples as estimate
- from pg_class
- where oid = 'auth.users'::regclass
-)
-select
- case
- when estimate = -1 then (select pg_temp.count_estimate('${escapedSelectSql}'))::int
- when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate('${escapedSelectSql}'))::int` : 'estimate::int'}
- else (${countBaseSql})
- end as count,
- estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate
-from approximation;
-`.trim()
-
- return sql
- }
-}
-
export async function getUsersCount(
{
projectRef,
@@ -105,7 +25,7 @@ export async function getUsersCount(
}: UsersCountVariables,
signal?: AbortSignal
) {
- const sql = getUsersCountSql({ filter, keywords, providers, forceExactCount })
+ const sql = getUsersCountSQL({ filter, keywords, providers, forceExactCount })
const { result } = await executeSql(
{
@@ -133,6 +53,7 @@ export async function getUsersCount(
export type UsersCountData = Awaited>
export type UsersCountError = ExecuteSqlError
+/** [Joshen] Be wary of using this as it could potentially cause a huge load on the user's DB */
export const useUsersCountQuery = (
{
projectRef,
diff --git a/apps/studio/data/auth/users-infinite-query.ts b/apps/studio/data/auth/users-infinite-query.ts
index 8fbf6d50ba079..2fed18be6dec1 100644
--- a/apps/studio/data/auth/users-infinite-query.ts
+++ b/apps/studio/data/auth/users-infinite-query.ts
@@ -1,3 +1,4 @@
+import { getPaginatedUsersSQL } from '@supabase/pg-meta/src/sql/studio/get-users-paginated'
import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query'
import type { components } from 'data/api'
@@ -6,131 +7,37 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { authKeys } from './keys'
-export type Filter = 'verified' | 'unverified' | 'anonymous'
-
-export type UsersVariables = {
+const USERS_PAGE_LIMIT = 50
+type UsersData = { result: User[] }
+type UsersError = ExecuteSqlError
+type UsersVariables = {
projectRef?: string
connectionString?: string | null
page?: number
keywords?: string
filter?: Filter
providers?: string[]
- sort?: 'created_at' | 'email' | 'phone' | 'last_sign_in_at'
+ sort?: 'id' | 'created_at' | 'email' | 'phone' | 'last_sign_in_at'
order?: 'asc' | 'desc'
-}
-export const USERS_PAGE_LIMIT = 50
-export type User = components['schemas']['UserBody'] & {
- providers: readonly string[]
+ column?: 'id' | 'email' | 'phone'
+ startAt?: string
}
-export const getUsersSQL = ({
- page = 0,
- verified,
- keywords,
- providers,
- sort,
- order,
-}: {
- page: number
- verified?: Filter
- keywords?: string
- providers?: string[]
- sort: string
- order: 'asc' | 'desc'
-}) => {
- const offset = page * USERS_PAGE_LIMIT
- const hasValidKeywords = keywords && keywords !== ''
-
- const conditions: string[] = []
-
- if (hasValidKeywords) {
- // [Joshen] Escape single quotes properly
- const formattedKeywords = keywords.replaceAll("'", "''")
- conditions.push(
- `id::text like '%${formattedKeywords}%' or email like '%${formattedKeywords}%' or phone like '%${formattedKeywords}%' or raw_user_meta_data->>'full_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'first_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'last_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'display_name' ilike '%${formattedKeywords}%'`
- )
- }
-
- 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`)
- }
-
- 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(', ')}]`
- )
- }
- }
-
- const combinedConditions = conditions.map((x) => `(${x})`).join(' and ')
- const sortOn = sort ?? 'created_at'
- const sortOrder = order ?? 'desc'
-
- const usersQuery = `
-with
- users_data as (
- select
- id,
- email,
- banned_until,
- created_at,
- confirmed_at,
- confirmation_sent_at,
- is_anonymous,
- is_sso_user,
- invited_at,
- last_sign_in_at,
- phone,
- raw_app_meta_data,
- raw_user_meta_data,
- updated_at
- from
- auth.users
- ${conditions.length > 0 ? ` where ${combinedConditions}` : ''}
- order by
- "${sortOn}" ${sortOrder} nulls last
- limit
- ${USERS_PAGE_LIMIT}
- offset
- ${offset}
- )
-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
-}
-
-export type UsersData = { result: User[] }
-export type UsersError = ExecuteSqlError
+export type Filter = 'verified' | 'unverified' | 'anonymous'
+export type User = components['schemas']['UserBody'] & { providers: readonly string[] }
export const useUsersInfiniteQuery = (
- { projectRef, connectionString, keywords, filter, providers, sort, order }: UsersVariables,
+ {
+ projectRef,
+ connectionString,
+ keywords,
+ filter,
+ providers,
+ sort,
+ order,
+ column,
+ }: UsersVariables,
{ enabled = true, ...options }: UseInfiniteQueryOptions = {}
) => {
const { data: project } = useSelectedProjectQuery()
@@ -143,13 +50,16 @@ export const useUsersInfiniteQuery = (
{
projectRef,
connectionString,
- sql: getUsersSQL({
- page: pageParam,
+ sql: getPaginatedUsersSQL({
+ page: column ? undefined : pageParam,
verified: filter,
keywords,
providers,
- sort: sort ?? 'created_at',
- order: order ?? 'desc',
+ sort: sort ?? 'id',
+ order: order ?? 'asc',
+ limit: USERS_PAGE_LIMIT,
+ column,
+ startAt: column ? pageParam : undefined,
}),
queryKey: authKeys.usersInfinite(projectRef),
},
@@ -159,10 +69,16 @@ export const useUsersInfiniteQuery = (
{
enabled: enabled && typeof projectRef !== 'undefined' && isActive,
getNextPageParam(lastPage, pages) {
- const page = pages.length
const hasNextPage = lastPage.result.length >= USERS_PAGE_LIMIT
- if (!hasNextPage) return undefined
- return page
+ if (column) {
+ const lastItem = lastPage.result[lastPage.result.length - 1]
+ if (hasNextPage && lastItem) return lastItem[column]
+ return undefined
+ } else {
+ const page = pages.length
+ if (!hasNextPage) return undefined
+ return page
+ }
},
...options,
}
diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts
index f6d57cb4a2ee6..fd888b8528c46 100644
--- a/apps/studio/data/table-rows/table-rows-count-query.ts
+++ b/apps/studio/data/table-rows/table-rows-count-query.ts
@@ -1,5 +1,10 @@
import { Query } from '@supabase/pg-meta/src/query'
+import {
+ COUNT_ESTIMATE_SQL,
+ THRESHOLD_COUNT,
+} from '@supabase/pg-meta/src/sql/studio/get-count-estimate'
import { QueryClient, useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query'
+
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
import type { Filter, SupaTable } from 'components/grid/types'
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
@@ -15,20 +20,6 @@ type GetTableRowsCountArgs = {
enforceExactCount?: boolean
}
-export const THRESHOLD_COUNT = 50000
-export const COUNT_ESTIMATE_SQL = /* SQL */ `
-CREATE OR REPLACE FUNCTION pg_temp.count_estimate(
- query text
-) RETURNS integer LANGUAGE plpgsql AS $$
-DECLARE
- plan jsonb;
-BEGIN
- EXECUTE 'EXPLAIN (FORMAT JSON)' || query INTO plan;
- RETURN plan->0->'Plan'->'Plan Rows';
-END;
-$$;
-`.trim()
-
export const getTableRowsCountSql = ({
table,
filters = [],
diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts
index 50cd45d16b2c0..43798337c5108 100644
--- a/apps/studio/data/table-rows/table-rows-query.ts
+++ b/apps/studio/data/table-rows/table-rows-query.ts
@@ -7,6 +7,7 @@ import {
type UseQueryOptions,
} from '@tanstack/react-query'
+import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate'
import { IS_PLATFORM } from 'common'
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types'
@@ -19,7 +20,6 @@ import {
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import { ExecuteSqlError, executeSql } from '../sql/execute-sql-query'
import { tableRowKeys } from './keys'
-import { THRESHOLD_COUNT } from './table-rows-count-query'
import { formatFilterValue } from './utils'
export interface GetTableRowsArgs {
diff --git a/packages/pg-meta/src/sql/studio/get-count-estimate.ts b/packages/pg-meta/src/sql/studio/get-count-estimate.ts
new file mode 100644
index 0000000000000..52c01e36d610f
--- /dev/null
+++ b/packages/pg-meta/src/sql/studio/get-count-estimate.ts
@@ -0,0 +1,14 @@
+export const THRESHOLD_COUNT = 50000
+
+export const COUNT_ESTIMATE_SQL = /* SQL */ `
+CREATE OR REPLACE FUNCTION pg_temp.count_estimate(
+ query text
+) RETURNS integer LANGUAGE plpgsql AS $$
+DECLARE
+ plan jsonb;
+BEGIN
+ EXECUTE 'EXPLAIN (FORMAT JSON)' || query INTO plan;
+ RETURN plan->0->'Plan'->'Plan Rows';
+END;
+$$;
+`.trim()
diff --git a/packages/pg-meta/src/sql/studio/get-users-count.ts b/packages/pg-meta/src/sql/studio/get-users-count.ts
new file mode 100644
index 0000000000000..1d1f5e6145d49
--- /dev/null
+++ b/packages/pg-meta/src/sql/studio/get-users-count.ts
@@ -0,0 +1,79 @@
+import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from './get-count-estimate'
+
+export const USERS_COUNT_ESTIMATE_SQL = `select reltuples as estimate from pg_class where oid = 'auth.users'::regclass`
+
+export const getUsersCountSQL = ({
+ filter,
+ keywords,
+ providers,
+ forceExactCount = false,
+}: {
+ filter?: 'verified' | 'unverified' | 'anonymous'
+ keywords?: string
+ providers?: string[]
+ forceExactCount?: boolean
+}) => {
+ const hasValidKeywords = keywords && keywords !== ''
+
+ const conditions: string[] = []
+ const baseQueryCount = `select count(*) from auth.users`
+ const baseQuerySelect = `select * from auth.users`
+
+ 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 (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(', ')}]`
+ )
+ }
+ }
+
+ const combinedConditions = conditions.map((x) => `(${x})`).join(' and ')
+ const whereClause = conditions.length > 0 ? ` where ${combinedConditions}` : ''
+
+ if (forceExactCount) {
+ return `select (${baseQueryCount}${whereClause}), false as is_estimate;`
+ } else {
+ const selectBaseSql = `${baseQuerySelect}${whereClause}`
+ const countBaseSql = `${baseQueryCount}${whereClause}`
+
+ const escapedSelectSql = selectBaseSql.replaceAll("'", "''")
+
+ const sql = `
+${COUNT_ESTIMATE_SQL}
+
+with approximation as (${USERS_COUNT_ESTIMATE_SQL})
+select
+ case
+ when estimate = -1 then (select pg_temp.count_estimate('${escapedSelectSql}'))::int
+ when estimate > ${THRESHOLD_COUNT} then ${conditions.length > 0 ? `(select pg_temp.count_estimate('${escapedSelectSql}'))::int` : 'estimate::int'}
+ else (${countBaseSql})
+ end as count,
+ estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate
+from approximation;
+`.trim()
+
+ return sql
+ }
+}
diff --git a/packages/pg-meta/src/sql/studio/get-users-paginated.ts b/packages/pg-meta/src/sql/studio/get-users-paginated.ts
new file mode 100644
index 0000000000000..44ea342adc6cd
--- /dev/null
+++ b/packages/pg-meta/src/sql/studio/get-users-paginated.ts
@@ -0,0 +1,182 @@
+interface getPaginatedUsersSQLProps {
+ page?: number
+ verified?: 'verified' | 'unverified' | 'anonymous'
+ keywords?: string
+ providers?: string[]
+ sort: string
+ order: 'asc' | 'desc'
+ 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'
+ 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,
+ keywords,
+ providers,
+ sort,
+ order,
+ limit = DEFAULT_LIMIT,
+
+ column,
+ startAt,
+}: getPaginatedUsersSQLProps) => {
+ // IMPORTANT: DO NOT CHANGE THESE QUERIES EVEN IN THE SLIGHTEST WITHOUT CONSULTING WITH AUTH TEAM.
+ const offset = page * limit
+ const hasValidKeywords = keywords && keywords !== ''
+
+ const conditions: string[] = []
+
+ if (hasValidKeywords) {
+ // [Joshen] Escape single quotes properly
+ const formattedKeywords = keywords.replaceAll("'", "''")
+ conditions.push(
+ `id::text like '%${formattedKeywords}%' or email like '%${formattedKeywords}%' or phone like '%${formattedKeywords}%' or raw_user_meta_data->>'full_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'first_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'last_name' ilike '%${formattedKeywords}%' or raw_user_meta_data->>'display_name' ilike '%${formattedKeywords}%'`
+ )
+ }
+
+ 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`)
+ }
+
+ 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(', ')}]`
+ )
+ }
+ }
+
+ const combinedConditions = conditions.map((x) => `(${x})`).join(' and ')
+ const sortOn = sort ?? 'created_at'
+ const sortOrder = order ?? 'desc'
+
+ let actualQuery = `${conditions.length > 0 ? ` where ${combinedConditions}` : ''}
+ order by
+ "${sortOn}" ${sortOrder} nulls last
+ limit
+ ${limit}
+ offset
+ ${offset}
+ `
+
+ // DON'T TOUCH THESE QUERIES. ONE CHARACTER OFF AND DISASTER.
+ let firstOperator = startAt ? '>' : '>='
+
+ if (column === 'email') {
+ const range = stringRange(keywords ?? '')
+
+ actualQuery = `where lower(email) ${firstOperator} '${startAt ? startAt : range[0]}' ${range[1] ? `and lower(email) < '${range[1]}'` : ''} and instance_id = '00000000-0000-0000-0000-000000000000'::uuid order by instance_id, lower(email) asc limit ${limit}`
+ } else if (column === 'phone') {
+ const range = stringRange(keywords ?? '')
+
+ actualQuery = `where phone ${firstOperator} '${startAt ? startAt : range[0]}' ${range[1] ? `and phone < '${range[1]}'` : ''} order by phone asc limit ${limit}`
+ } else if (column === 'id') {
+ const isMatchingUUIDValue = prefixToUUID(keywords ?? '', false) === keywords
+ if (isMatchingUUIDValue) {
+ actualQuery = `where id = '${keywords}' order by id asc limit ${limit}`
+ } else {
+ actualQuery = `where id ${firstOperator} '${startAt ? startAt : prefixToUUID(keywords ?? '', false)}' and id < '${prefixToUUID(keywords ?? '', true)}' order by id asc limit ${limit}`
+ }
+ }
+
+ let 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,
+ auth.users.updated_at
+ from
+ auth.users
+ ${actualQuery}`
+
+ let 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
+}