mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
Moves studio specific SQL queries out of pg-meta into studio itself
This commit is contained in:
1 parent
6437bd2a38
commit
28ecc8b4db
15 files changed
+587
-35
No files matched your search
@@ -1,4 +1,3 @@
|
||||
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
|
||||
@@ -20,6 +19,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { DropdownControl } from '../../common/DropdownControl'
|
||||
import { formatEstimatedCount } from './Pagination.utils'
|
||||
import { THRESHOLD_COUNT } from '@/data/table-rows/table-rows.sql'
|
||||
|
||||
const rowsPerPageOptions = [
|
||||
{ value: 100, label: '100 rows' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
|
||||
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
import { OptimizedSearchColumns } from '@/data/auth/auth.sql'
|
||||
|
||||
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { HelpCircle, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
import { useParams } from 'common'
|
||||
import { formatEstimatedCount } from 'components/grid/components/footer/pagination/Pagination.utils'
|
||||
import { useUsersCountQuery } from 'data/auth/users-count-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { HelpCircle, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import type { Filter, SpecificFilterColumn } from './Users.constants'
|
||||
import { OptimizedSearchColumns } from '@/data/auth/auth.sql'
|
||||
import { THRESHOLD_COUNT } from '@/data/table-rows/table-rows.sql'
|
||||
|
||||
interface UsersFooterProps {
|
||||
filter: Filter
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
|
||||
@@ -75,6 +73,7 @@ import {
|
||||
import { formatUserColumns, formatUsersData } from './Users.utils'
|
||||
import { UsersFooter } from './UsersFooter'
|
||||
import { UsersSearch } from './UsersSearch'
|
||||
import { USER_SEARCH_INDEXES, type OptimizedSearchColumns } from '@/data/auth/auth.sql'
|
||||
import { PROJECT_STATUS } from '@/lib/constants/infrastructure'
|
||||
|
||||
const SORT_BY_VALUE_COUNT_THRESHOLD = 10_000
|
||||
@@ -215,7 +214,7 @@ export const UsersV2 = () => {
|
||||
const isImprovedUserSearchFlagEnabled = useFlag('improvedUserSearch')
|
||||
const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
|
||||
const {
|
||||
data: userSearchIndexes,
|
||||
data: userSearchIndexes = [],
|
||||
isError: isUserSearchIndexesError,
|
||||
isLoading: isUserSearchIndexesLoading,
|
||||
} = useUserIndexStatusesQuery({ projectRef, connectionString: project?.connectionString })
|
||||
@@ -246,7 +245,7 @@ export const UsersV2 = () => {
|
||||
const userSearchIndexesAreValidAndReady =
|
||||
!isUserSearchIndexesError &&
|
||||
!isUserSearchIndexesLoading &&
|
||||
userSearchIndexes?.length === pgMeta.USER_SEARCH_INDEXES.length &&
|
||||
userSearchIndexes?.length === USER_SEARCH_INDEXES.length &&
|
||||
userSearchIndexes?.every((index) => index.is_valid && index.is_ready)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,71 @@
|
||||
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
|
||||
@@ -33,3 +101,441 @@ where id = '${userId}';
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql, type ExecuteSqlError } from 'data/sql/execute-sql-query'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { getIndexWorkerStatusSQL } from './auth.sql'
|
||||
import { authKeys } from './keys'
|
||||
|
||||
type IndexWorkerStatusVariables = {
|
||||
@@ -18,7 +18,7 @@ export async function getIndexWorkerStatus(
|
||||
{ projectRef, connectionString }: IndexWorkerStatusVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<IndexWorkerStatusData> {
|
||||
const sql = pgMeta.getIndexWorkerStatusSQL()
|
||||
const sql = getIndexWorkerStatusSQL()
|
||||
|
||||
const { result } = await executeSql<IndexWorkerStatusData[]>(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OptimizedSearchColumns } from '@supabase/pg-meta/src/sql/studio/get-users-types'
|
||||
import { type OptimizedSearchColumns } from './auth.sql'
|
||||
|
||||
export const authKeys = {
|
||||
user: (projectRef: string | undefined, userId?: string | null) =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql, type ExecuteSqlError } from 'data/sql/execute-sql-query'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { getIndexStatusesSQL } from './auth.sql'
|
||||
import { authKeys } from './keys'
|
||||
|
||||
type UsersIndexStatusesVariables = {
|
||||
@@ -20,7 +20,7 @@ export async function getUserIndexStatuses(
|
||||
{ projectRef, connectionString }: UsersIndexStatusesVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<UsersIndexStatusesData> {
|
||||
const sql = pgMeta.getIndexStatusesSQL()
|
||||
const sql = getIndexStatusesSQL()
|
||||
|
||||
const { result } = await executeSql<UsersIndexStatusesData>(
|
||||
{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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'
|
||||
|
||||
import { executeSql, type ExecuteSqlError } from 'data/sql/execute-sql-query'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { getUsersCountSQL, type OptimizedSearchColumns } from './auth.sql'
|
||||
import { authKeys } from './keys'
|
||||
import { type Filter } from './users-infinite-query'
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
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'
|
||||
import type { components } from 'data/api'
|
||||
import { executeSql, ExecuteSqlError } from 'data/sql/execute-sql-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { UseCustomInfiniteQueryOptions } from 'types'
|
||||
|
||||
import { getPaginatedUsersSQL, UsersCursor, type OptimizedSearchColumns } from './auth.sql'
|
||||
import { authKeys } from './keys'
|
||||
|
||||
const USERS_PAGE_LIMIT = 50
|
||||
|
||||
@@ -18,3 +18,24 @@ export const getLiveTupleEstimateKey = (
|
||||
table: string,
|
||||
schema = 'public'
|
||||
) => sqlKeys.query(projectRef, ['live-tuple-estimate', schema, table])
|
||||
|
||||
export const getCheckPrimaryKeysExistsSQL = (tables: { name: string; schema: string }[]) => {
|
||||
const formattedTables = tables.map((table) => `'${table.schema}.${table.name}'`).join(',')
|
||||
|
||||
return /* SQL */ `
|
||||
WITH targets(rel) AS (
|
||||
SELECT unnest(ARRAY[${formattedTables}]::regclass[])
|
||||
)
|
||||
SELECT
|
||||
c.oid AS id,
|
||||
n.nspname AS schema,
|
||||
c.relname AS name,
|
||||
(con.conrelid IS NOT NULL) AS has_primary_key
|
||||
FROM targets t
|
||||
JOIN pg_class c ON c.oid = t.rel
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
LEFT JOIN pg_constraint con
|
||||
ON con.conrelid = c.oid AND con.contype = 'p'
|
||||
ORDER BY n.nspname, c.relname;
|
||||
`
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getCheckPrimaryKeysExistsSQL } from '@supabase/pg-meta/src/sql/studio/check-primary-keys-exists'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { getCheckPrimaryKeysExistsSQL } from './database.sql'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
type CheckPrimaryKeysExistsVariables = {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
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 { GetTableRowsCountArgs } from './table-rows-count-query'
|
||||
import { formatFilterValue } from './utils'
|
||||
|
||||
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()
|
||||
|
||||
/**
|
||||
* [Joshen] Initially check reltuples from pg_class for an estimate of row count on the table
|
||||
* - If reltuples = -1, table never been analyzed, assume small table -> return exact count
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getTablesWithAnonAuthenticatedAccessSQL } from '@supabase/pg-meta/src/sql/studio/check-tables-anon-authenticated-access'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
|
||||
import { tableKeys } from './keys'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
import { getTablesWithAnonAuthenticatedAccessSQL } from './tables.sql'
|
||||
|
||||
type TablesRolesAccessArgs = {
|
||||
schema: string
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Given a schema name, list all the tables in that schema which have access
|
||||
* granted to either the "anon" or "authenticated" role
|
||||
*/
|
||||
export const getTablesWithAnonAuthenticatedAccessSQL = ({ schema }: { schema: string }) =>
|
||||
/* SQL */ `
|
||||
SELECT c.relname AS table_name
|
||||
FROM pg_catalog.pg_class AS c
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = '${schema}'
|
||||
AND c.relkind IN ('r','p') -- table, partitioned table
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.aclexplode(COALESCE(c.relacl, '{}'::aclitem[])) AS a
|
||||
JOIN pg_catalog.pg_roles r ON r.oid = a.grantee
|
||||
WHERE r.rolname IN ('anon','authenticated')
|
||||
)
|
||||
;
|
||||
`.trim()
|
||||
Reference in new issue
Block a user