From 28ecc8b4db1ebbbd447fbbc0b8258c7bf887368f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 5 Mar 2026 18:05:43 +0800 Subject: [PATCH] Moves studio specific SQL queries out of pg-meta into studio itself --- .../footer/pagination/Pagination.tsx | 2 +- .../interfaces/Auth/Users/Users.constants.ts | 3 +- .../interfaces/Auth/Users/UsersFooter.tsx | 10 +- .../interfaces/Auth/Users/UsersV2.tsx | 7 +- apps/studio/data/auth/auth.sql.ts | 506 ++++++++++++++++++ .../data/auth/index-worker-status-query.ts | 6 +- apps/studio/data/auth/keys.ts | 2 +- .../data/auth/user-search-indexes-query.ts | 6 +- apps/studio/data/auth/users-count-query.ts | 5 +- apps/studio/data/auth/users-infinite-query.ts | 8 +- apps/studio/data/database/database.sql.ts | 21 + .../database/primary-keys-exists-query.ts | 4 +- apps/studio/data/table-rows/table-rows.sql.ts | 19 +- .../data/tables/tables-roles-access-query.ts | 4 +- apps/studio/data/tables/tables.sql.ts | 19 + 15 files changed, 587 insertions(+), 35 deletions(-) create mode 100644 apps/studio/data/tables/tables.sql.ts diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 4a4b2914742..9746f27b2fb 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -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' }, diff --git a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts index 7712f50590d..5c4ace6d90a 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts +++ b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts @@ -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' diff --git a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx index e13281dc0a7..cee59164cc8 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersFooter.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 2bd8d05201f..abdb30fb148 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -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) /** diff --git a/apps/studio/data/auth/auth.sql.ts b/apps/studio/data/auth/auth.sql.ts index cbb03efd0bb..7ebf3cf3e20 100644 --- a/apps/studio/data/auth/auth.sql.ts +++ b/apps/studio/data/auth/auth.sql.ts @@ -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 +} diff --git a/apps/studio/data/auth/index-worker-status-query.ts b/apps/studio/data/auth/index-worker-status-query.ts index a86b087e050..40fe0e15efa 100644 --- a/apps/studio/data/auth/index-worker-status-query.ts +++ b/apps/studio/data/auth/index-worker-status-query.ts @@ -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 { - const sql = pgMeta.getIndexWorkerStatusSQL() + const sql = getIndexWorkerStatusSQL() const { result } = await executeSql( { diff --git a/apps/studio/data/auth/keys.ts b/apps/studio/data/auth/keys.ts index e7e83801c7f..dc01b5bf7b1 100644 --- a/apps/studio/data/auth/keys.ts +++ b/apps/studio/data/auth/keys.ts @@ -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) => diff --git a/apps/studio/data/auth/user-search-indexes-query.ts b/apps/studio/data/auth/user-search-indexes-query.ts index 2b95392ab38..775b0d80ae0 100644 --- a/apps/studio/data/auth/user-search-indexes-query.ts +++ b/apps/studio/data/auth/user-search-indexes-query.ts @@ -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 { - const sql = pgMeta.getIndexStatusesSQL() + const sql = getIndexStatusesSQL() const { result } = await executeSql( { diff --git a/apps/studio/data/auth/users-count-query.ts b/apps/studio/data/auth/users-count-query.ts index 7d78176ad4f..f4b2b0fee77 100644 --- a/apps/studio/data/auth/users-count-query.ts +++ b/apps/studio/data/auth/users-count-query.ts @@ -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' diff --git a/apps/studio/data/auth/users-infinite-query.ts b/apps/studio/data/auth/users-infinite-query.ts index 0db05a99c2e..4147829f95a 100644 --- a/apps/studio/data/auth/users-infinite-query.ts +++ b/apps/studio/data/auth/users-infinite-query.ts @@ -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 diff --git a/apps/studio/data/database/database.sql.ts b/apps/studio/data/database/database.sql.ts index e1408aa15bb..cd74d7ccfd8 100644 --- a/apps/studio/data/database/database.sql.ts +++ b/apps/studio/data/database/database.sql.ts @@ -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; +` +} diff --git a/apps/studio/data/database/primary-keys-exists-query.ts b/apps/studio/data/database/primary-keys-exists-query.ts index fb3091d0a76..b1a553890fe 100644 --- a/apps/studio/data/database/primary-keys-exists-query.ts +++ b/apps/studio/data/database/primary-keys-exists-query.ts @@ -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 = { diff --git a/apps/studio/data/table-rows/table-rows.sql.ts b/apps/studio/data/table-rows/table-rows.sql.ts index 5c5d0947bc5..3eeb94ea235 100644 --- a/apps/studio/data/table-rows/table-rows.sql.ts +++ b/apps/studio/data/table-rows/table-rows.sql.ts @@ -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 diff --git a/apps/studio/data/tables/tables-roles-access-query.ts b/apps/studio/data/tables/tables-roles-access-query.ts index 32f5255f3b1..a4ef894336b 100644 --- a/apps/studio/data/tables/tables-roles-access-query.ts +++ b/apps/studio/data/tables/tables-roles-access-query.ts @@ -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 diff --git a/apps/studio/data/tables/tables.sql.ts b/apps/studio/data/tables/tables.sql.ts new file mode 100644 index 00000000000..67b80967b09 --- /dev/null +++ b/apps/studio/data/tables/tables.sql.ts @@ -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()