Files
supabase/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts
oniani1 39f1358f08 fix(studio): encode special characters in table editor policy links (#45846)
Closes #45845.

## Summary

`GridHeaderActions.tsx` interpolated `table.name` and `table.schema`
directly into the policies URL at two `<Link href>` builders. A table or
schema containing `&`, `=`, `+`, or `#` corrupted the destination and
routed users to the wrong policies filter.

Extracts the URL into `getTablePoliciesUrl` in `TableEntity.utils.ts`
with `encodeURIComponent` wraps, and replaces both inline
interpolations. Same pattern as #45385 (Linter shortcut links).

## Test plan

Added four `getTablePoliciesUrl` cases in `TableEntity.utils.test.ts`:
plain values, special chars in name, special chars in schema, special
chars in both. Existing seven tests in the same file still pass.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Improved Row-Level Security (RLS) policy URL handling in the table
editor using a shared utility function for consistent URL building and
proper parameter encoding.

* **Tests**
* Added test coverage for RLS policy URL generation with various
parameter combinations and special character handling.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45846)

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 13:35:59 -04:00

140 lines
4.1 KiB
TypeScript

import { SupaTable } from '@/components/grid/types'
import { Lint } from '@/data/lint/lint-query'
export const getEntityLintDetails = (
entityName: string,
lintName: string,
lintLevels: ('ERROR' | 'WARN' | 'INFO')[],
lints: Lint[],
schema: string
): { hasLint: boolean; count: number; matchingLint: Lint | null } => {
const matchingLint =
lints?.find(
(lint) =>
lint?.metadata?.name === entityName &&
lint?.metadata?.schema === schema &&
lint?.name === lintName &&
lintLevels.includes(lint?.level)
) || null
return {
hasLint: matchingLint !== null,
count: matchingLint ? 1 : 0,
matchingLint,
}
}
export const getTablePoliciesUrl = (
projectRef: string | undefined,
schema: string | undefined,
name: string | undefined
): string => {
return `/project/${projectRef ?? ''}/auth/policies?search=${encodeURIComponent(
name ?? ''
)}&schema=${encodeURIComponent(schema ?? '')}`
}
export const formatTableRowsToSQL = (table: SupaTable, rows: any[]) => {
if (rows.length === 0) return ''
const columns = table.columns.map((col) => `"${col.name}"`).join(', ')
const valuesSets = rows
.map((row) => {
const filteredRow = { ...row }
if ('idx' in filteredRow) delete filteredRow.idx
const values = Object.entries(filteredRow).map(([key, val]) => {
const { dataType, format } = table.columns.find((col) => col.name === key) ?? {}
// We only check for NULL, array and JSON types, everything else we stringify
// given that Postgres can implicitly cast the right type based on the column type
// For string types, we need to deal with escaping single quotes
const stringFormats = ['text', 'varchar']
if (val === null) {
return 'null'
} else if (dataType === 'ARRAY') {
const array = Array.isArray(val) ? val : JSON.parse(val as string)
return `${formatArrayForSql(array as unknown[])}`
} else if (format?.includes('json')) {
return `${JSON.stringify(val).replace(/\\"/g, '"').replace(/'/g, "''").replace('"', "'").replace(/.$/, "'")}`
} else if (
typeof format === 'string' &&
typeof val === 'string' &&
stringFormats.includes(format)
) {
return `'${val.replaceAll("'", "''")}'`
} else if (typeof val === 'number' || typeof val === 'boolean') {
return `${val}`
} else if (typeof val === 'string') {
return `'${val.replaceAll("'", "''")}'`
} else {
return `'${val}'`
}
})
return `(${values.join(', ')})`
})
.join(', ')
return `INSERT INTO "${table.schema}"."${table.name}" (${columns}) VALUES ${valuesSets};`
}
/**
* Generate a random tag for dollar-quoting of SQL strings
*
* @return A random tag in the format `$tag$`
*/
const generateRandomTag = (): `$${string}$` => {
const inner = Math.random().toString(36).substring(2, 15)
// Ensure the tag starts with a character not a digit to avoid conflicts with
// Postgres parameter syntax
return `$x${inner}$`
}
/**
* Wrap a string in dollar-quote tags, ensuring the tag does not appear in the string
*
* @throws Error if unable to generate a unique dollar-quote tag after multiple attempts
*/
const safeDollarQuote = (str: string): string => {
let tag = generateRandomTag()
let attempts = 0
const maxAttempts = 100
while (str.includes(tag)) {
if (attempts >= maxAttempts) {
throw new Error('Unable to generate a unique dollar-quote tag after multiple attempts.')
}
attempts++
tag = generateRandomTag()
}
return `${tag}${str}${tag}`
}
const formatArrayForSql = (arr: unknown[]): string => {
let result = 'ARRAY['
arr.forEach((item, index) => {
if (Array.isArray(item)) {
result += formatArrayForSql(item)
} else if (typeof item === 'string') {
result += `'${item.replaceAll("'", "''")}'`
} else if (!!item && typeof item === 'object') {
result += `${safeDollarQuote(JSON.stringify(item))}::json`
} else {
result += `${item}`
}
if (index < arr.length - 1) {
result += ','
}
})
result += ']'
return result
}