mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
28cd7ada35
commit
b1a9e072ec
4 files changed
+85
-8
No files matched your search
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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 }): {
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in new issue
Block a user