mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
542 lines
17 KiB
TypeScript
542 lines
17 KiB
TypeScript
import { ident, literal } from '@supabase/pg-meta/src/pg-format'
|
|
|
|
import { COUNT_ESTIMATE_SQL, THRESHOLD_COUNT } from '../table-rows/table-rows.sql'
|
|
|
|
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): [string, string | undefined] {
|
|
if (!prefix) {
|
|
return [prefix, undefined]
|
|
}
|
|
|
|
const lastCharCode = prefix.charCodeAt(prefix.length - 1)
|
|
const TILDE_CHAR_CODE = 126 // '~'
|
|
const Z_CHAR_CODE = 122 // 'z'
|
|
|
|
// 'z' (122): append '~' to avoid PostgreSQL collation issues with '{'
|
|
if (lastCharCode === Z_CHAR_CODE) {
|
|
return [prefix, prefix + '~']
|
|
}
|
|
|
|
// '~' (126) or beyond: append space since we can't increment further
|
|
if (lastCharCode >= TILDE_CHAR_CODE) {
|
|
return [prefix, prefix + ' ']
|
|
}
|
|
|
|
// All other characters: increment the last character
|
|
const upperBound = prefix.substring(0, prefix.length - 1) + String.fromCharCode(lastCharCode + 1)
|
|
return [prefix, upperBound]
|
|
}
|
|
|
|
export type OptimizedSearchColumns = 'id' | 'email' | 'phone' | 'name'
|
|
|
|
export const USER_SEARCH_INDEXES = [
|
|
'idx_users_email',
|
|
'idx_users_created_at_desc',
|
|
'idx_users_last_sign_in_at_desc',
|
|
'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',
|
|
]
|
|
|
|
export const getUserSQL = (userId: string) => {
|
|
const sql = /* SQL */ `
|
|
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,
|
|
coalesce(
|
|
(
|
|
select
|
|
array_agg(distinct i.provider)
|
|
from
|
|
auth.identities i
|
|
where
|
|
i.user_id = users.id
|
|
),
|
|
'{}'::text[]
|
|
) as providers
|
|
from
|
|
auth.users
|
|
where id = '${userId}';
|
|
`.trim()
|
|
|
|
return sql
|
|
}
|
|
|
|
export const getIndexStatusesSQL = () => {
|
|
return `SELECT c.relname as index_name, i.indisvalid as is_valid, i.indisready as is_ready
|
|
FROM pg_index i
|
|
JOIN pg_class c ON c.oid = i.indexrelid
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'auth'
|
|
AND c.relname IN (${USER_SEARCH_INDEXES.map(literal).join(', ')});`
|
|
}
|
|
|
|
// Checks pg_locks to determine if the index worker advisory lock is currently held
|
|
export const getIndexWorkerStatusSQL = () => {
|
|
return `SELECT EXISTS (
|
|
SELECT 1 FROM pg_locks
|
|
WHERE locktype = 'advisory'
|
|
AND (classid::bigint << 32 | objid::bigint) = hashtext('auth_index_worker')::bigint
|
|
) as is_in_progress;`
|
|
}
|
|
|
|
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,
|
|
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 !== ''
|
|
|
|
const conditions: string[] = []
|
|
const baseQueryCount = `select count(*) from auth.users`
|
|
const baseQuerySelect = `select * from auth.users`
|
|
|
|
const optimizedSearchMode = column && hasValidKeywords
|
|
if (optimizedSearchMode) {
|
|
if (column === 'email') {
|
|
const range = stringRange(keywords)
|
|
const lowerBound = literal(range[0])
|
|
const upperBound = range[1] ? literal(range[1]) : null
|
|
conditions.push(
|
|
`lower(email) >= ${lowerBound}${upperBound ? ` and lower(email) < ${upperBound}` : ''} and instance_id = '00000000-0000-0000-0000-000000000000'::uuid`
|
|
)
|
|
} else if (column === 'phone') {
|
|
const range = stringRange(keywords)
|
|
const lowerBound = literal(range[0])
|
|
const upperBound = range[1] ? literal(range[1]) : null
|
|
conditions.push(`phone >= ${lowerBound}${upperBound ? ` and phone < ${upperBound}` : ''}`)
|
|
} else if (column === 'id') {
|
|
const lowerUUID = prefixToUUID(keywords, false)
|
|
const isMatchingUUIDValue = lowerUUID === keywords
|
|
if (isMatchingUUIDValue) {
|
|
conditions.push(`id = ${literal(keywords)}`)
|
|
} else {
|
|
const upperUUID = prefixToUUID(keywords, true)
|
|
conditions.push(`id >= ${literal(lowerUUID)} and id < ${literal(upperUUID)}`)
|
|
}
|
|
}
|
|
} else {
|
|
// Unified search mode - apply all filters
|
|
if (hasValidKeywords) {
|
|
const escapedFilterKeywords = literal(`%${keywords}%`)
|
|
conditions.push(
|
|
`id::text ilike ${escapedFilterKeywords} or email ilike ${escapedFilterKeywords} or phone ilike ${escapedFilterKeywords}`
|
|
)
|
|
}
|
|
|
|
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')) {
|
|
const mappedProviders = providers.map((p) => (p === 'saml 2.0' ? 'sso' : p))
|
|
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[${literal(mappedProviders)}]`.trim()
|
|
)
|
|
} else {
|
|
conditions.push(`(raw_app_meta_data->>'providers')::jsonb ?| array[${literal(providers)}]`)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = literal(selectBaseSql)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
const DEFAULT_LIMIT = 50
|
|
|
|
export interface UsersCursor {
|
|
sort: string
|
|
id: string
|
|
}
|
|
|
|
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?: OptimizedSearchColumns
|
|
startAt?: string
|
|
|
|
/** Cursor for cursor-based pagination (used by improved search) */
|
|
cursor?: UsersCursor
|
|
improvedSearchEnabled?: boolean
|
|
}
|
|
|
|
export const getPaginatedUsersSQL = ({
|
|
page = 0,
|
|
verified,
|
|
keywords,
|
|
providers,
|
|
sort,
|
|
order,
|
|
limit = DEFAULT_LIMIT,
|
|
|
|
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 !== ''
|
|
|
|
const conditions: string[] = []
|
|
|
|
if (hasValidKeywords) {
|
|
const pattern = `%${keywords}%`
|
|
conditions.push(
|
|
`id::text like ${literal(pattern)} or email like ${literal(pattern)} or phone like ${literal(pattern)} or raw_user_meta_data->>'full_name' ilike ${literal(pattern)} or raw_user_meta_data->>'first_name' ilike ${literal(pattern)} or raw_user_meta_data->>'last_name' ilike ${literal(pattern)} or raw_user_meta_data->>'display_name' ilike ${literal(pattern)}`
|
|
)
|
|
}
|
|
|
|
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) => literal(p === 'saml 2.0' ? 'sso' : p)).join(', ')}]`.trim()
|
|
)
|
|
} else {
|
|
conditions.push(
|
|
`(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => literal(p)).join(', ')}]`
|
|
)
|
|
}
|
|
}
|
|
|
|
const combinedConditions = conditions.map((x) => `(${x})`).join(' and ')
|
|
const sortOn = sort ?? 'created_at'
|
|
const sortOrder = order ?? 'desc'
|
|
|
|
let whereStatement = `${conditions.length > 0 ? ` where ${combinedConditions}` : ''}
|
|
order by
|
|
${ident(sortOn)} ${sortOrder} nulls last
|
|
limit
|
|
${limit}
|
|
offset
|
|
${offset}
|
|
`
|
|
|
|
// DON'T TOUCH THESE QUERIES. ONE CHARACTER OFF AND DISASTER.
|
|
const firstOperator = startAt ? '>' : '>='
|
|
|
|
if (column === 'email') {
|
|
const range = stringRange(keywords ?? '')
|
|
|
|
whereStatement = `where lower(email) ${firstOperator} ${literal(startAt ? startAt : range[0])} ${range[1] ? `and lower(email) < ${literal(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 ?? '')
|
|
whereStatement = `where phone ${firstOperator} ${literal(startAt ? startAt : range[0])} ${range[1] ? `and phone < ${literal(range[1])}` : ''} order by phone asc limit ${limit}`
|
|
} else if (column === 'id') {
|
|
const isMatchingUUIDValue = prefixToUUID(keywords ?? '', false) === keywords
|
|
if (isMatchingUUIDValue) {
|
|
whereStatement = `where id = ${literal(keywords)} order by id asc limit ${limit}`
|
|
} else {
|
|
whereStatement = `where id ${firstOperator} ${literal(startAt ? startAt : prefixToUUID(keywords ?? '', false))} and id < ${literal(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
|
|
${whereStatement}`
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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 conditions: string[] = []
|
|
|
|
// Column-specific search condition
|
|
if (hasValidKeywords) {
|
|
if (column === 'email') {
|
|
// Use btree index with prefix matching
|
|
const range = stringRange(keywords)
|
|
if (range[1]) {
|
|
conditions.push(`email >= ${literal(range[0])} AND email < ${literal(range[1])}`)
|
|
} else {
|
|
conditions.push(`email >= ${literal(range[0])}`)
|
|
}
|
|
} else if (column === 'phone') {
|
|
// Use btree index with prefix matching
|
|
const range = stringRange(keywords)
|
|
if (range[1]) {
|
|
conditions.push(`phone >= ${literal(range[0])} AND phone < ${literal(range[1])}`)
|
|
} else {
|
|
conditions.push(`phone >= ${literal(range[0])}`)
|
|
}
|
|
} else if (column === 'id') {
|
|
// Exact match on UUID
|
|
conditions.push(`id = ${literal(keywords)}`)
|
|
} else if (column === 'name') {
|
|
// Use btree index with prefix matching on raw_user_meta_data->>'name'
|
|
const range = stringRange(keywords)
|
|
if (range[1]) {
|
|
conditions.push(
|
|
`raw_user_meta_data->>'name' >= ${literal(range[0])} AND raw_user_meta_data->>'name' < ${literal(range[1])}`
|
|
)
|
|
} else {
|
|
conditions.push(`raw_user_meta_data->>'name' >= ${literal(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) => literal(p === 'saml 2.0' ? 'sso' : p)).join(', ')}]`
|
|
)
|
|
} else {
|
|
conditions.push(
|
|
`(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => literal(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} ${literal(cursor.id)}::uuid`)
|
|
} else {
|
|
conditions.push(
|
|
`(${ident(sortOn)}, id) ${operator} (${literal(cursor.sort)}, ${literal(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'
|
|
? `${ident(sortOn)} ${sortOrder}`
|
|
: `${ident(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
|
|
}
|