Update table editor ilike related comparators to implicitly wrap filter string with % if not provided (#50394)

### Context

For table editor - the `ilike` related comparators expect users to input
a `%` in the filter string, which for non-developers might not be
intuitive.

Hence opting to implicitly wrap the filter string with `%` in the query
when filtering if non provided
<img width="1182" height="755" alt="image"
src="https://github.com/user-attachments/assets/819c39f5-fcbf-4213-95b3-3ad1ee901f47"
/>


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved pattern-match filters so bare text values perform contains
matching by automatically wrapping them with wildcards.
* Preserved explicit wildcard patterns using `%` or `_` without adding
additional wildcards.
* Improved handling of empty values for non-text filters while retaining
existing numeric filter validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-16 14:32:19 +08:00
1 parent 28cd7ada35
commit b1a9e072ec
4 files changed
+85 -8

No files matched your search

+18 -2
View File
@@ -12,9 +12,9 @@ const table = {
],
}
const makeFilter = (column: string, value: any): Filter => ({
const makeFilter = (column: string, value: any, operator: Filter['operator'] = '='): Filter => ({
column,
operator: '=',
operator,
value,
})
@@ -56,4 +56,20 @@ describe('formatFilterValue', () => {
Number.MIN_SAFE_INTEGER
)
})
it.each(['~~', '~~*', '!~~', '!~~*'] as const)(
'wraps the value in %%...%% for the %s operator when no wildcard is present',
(operator) => {
expect(formatFilterValue(table, makeFilter('name', 'al', operator))).toBe('%al%')
}
)
it.each(['~~', '~~*', '!~~', '!~~*'] as const)(
'leaves the value untouched for the %s operator when it already contains a % or _ wildcard',
(operator) => {
expect(formatFilterValue(table, makeFilter('name', 'al%', operator))).toBe('al%')
expect(formatFilterValue(table, makeFilter('name', '%al', operator))).toBe('%al')
expect(formatFilterValue(table, makeFilter('name', 'j_hn', operator))).toBe('j_hn')
}
)
})
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatPatternMatchFilterValue } from '@supabase/pg-meta/src/query/table-row-query'
import type { Filter, ServiceError } from '@/components/grid/types'
import { isNumericalColumn } from '@/components/grid/utils/types'
import { Entity, isTableLike } from '@/data/table-editor/table-editor-types'
@@ -22,7 +24,7 @@ export function formatFilterValue(
return filter.value
else return numberValue
}
return filter.value
return formatPatternMatchFilterValue(filter.value, filter.operator)
}
export function getPrimaryKeys({ table }: { table: Entity }): {
+24 -5
View File
@@ -87,6 +87,25 @@ const LARGE_COLUMNS_TYPES_SET = new Set(LARGE_COLUMNS_TYPES)
// Threshold count for applying default sort
export const THRESHOLD_COUNT = 100000
// Operators that perform SQL pattern matching, where a bare value with no `%`/`_`
// wildcard should be treated as a "contains" search rather than an exact match
const PATTERN_MATCH_OPERATORS: Filter['operator'][] = ['~~', '~~*', '!~~', '!~~*']
export function formatPatternMatchFilterValue(
value: Filter['value'],
operator: Filter['operator']
) {
if (
PATTERN_MATCH_OPERATORS.includes(operator) &&
typeof value === 'string' &&
!value.includes('%') &&
!value.includes('_')
) {
return `%${value}%`
}
return value
}
// Return the primary key columns if exists, otherwise return the first column to use as a default sort
export const getDefaultOrderByColumns = (
table: Pick<PGTable, 'primary_keys' | 'columns'>,
@@ -149,11 +168,11 @@ export const getTableRowsSql = ({
filters.forEach((x) => {
const col = table.columns?.find((y) => y.name === x.column)
const isStringTypeColumn = !!col ? (TEXT_TYPES as string[]).includes(col.format) : true
queryChains = queryChains.filter(
x.column,
x.operator,
!isStringTypeColumn && x.value === '' ? null : x.value
)
const value =
!isStringTypeColumn && x.value === ''
? null
: formatPatternMatchFilterValue(x.value, x.operator)
queryChains = queryChains.filter(x.column, x.operator, value)
})
// If sorts is empty and table row count is within threshold, use the primary key as the default sort.
@@ -1142,6 +1142,46 @@ describe('Table Row Query', () => {
expect(queryResult.every((row: any) => row.category === 'A')).toBe(true)
})
withTestDatabase(
'should wrap a bare ilike filter value in wildcards for a contains match',
async (db) => {
// Create test table and insert data
await db.executeQuery(`
CREATE TABLE test_sql_filter_ilike (
id SERIAL PRIMARY KEY,
name TEXT
);
INSERT INTO test_sql_filter_ilike (name) VALUES
('Alizarin'),
('Amber'),
('Coral');
`)
// Get table metadata
const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
const tables = tablesZod.parse(await db.executeQuery(tablesSql))
const testTable = tables.find((table) => table.name === 'test_sql_filter_ilike')
expect(testTable).toBeDefined()
// A bare value with no `%`/`_` should be treated as a "contains" search
const filters: Filter[] = [{ column: 'name', operator: '~~*', value: 'al' }]
const sql = getTableRowsSql({
table: testTable!,
filters,
page: 1,
limit: 10,
})
expect(sql).toContain(`name::text ~~* '%al%'`)
const queryResult = await db.executeQuery(sql)
expect(queryResult.map((row: any) => row.name).sort()).toEqual(['Alizarin', 'Coral'])
}
)
withTestDatabase('should generate SQL with sorting', async (db) => {
// Create test table and insert data
await db.executeQuery(`