From b1a9e072ec9410b9b4a265cc7edac3bd02fab275 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 16 Sep 2026 14:32:19 +0800 Subject: [PATCH] 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 image ## 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. --- apps/studio/data/table-rows/utils.test.ts | 20 +++++++++- apps/studio/data/table-rows/utils.ts | 4 +- packages/pg-meta/src/query/table-row-query.ts | 29 +++++++++++--- .../test/query/table-row-query.test.ts | 40 +++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/apps/studio/data/table-rows/utils.test.ts b/apps/studio/data/table-rows/utils.test.ts index d6e449618d8..220fdfcbc48 100644 --- a/apps/studio/data/table-rows/utils.test.ts +++ b/apps/studio/data/table-rows/utils.test.ts @@ -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') + } + ) }) diff --git a/apps/studio/data/table-rows/utils.ts b/apps/studio/data/table-rows/utils.ts index 80aa54d9ded..996ef1f2c6e 100644 --- a/apps/studio/data/table-rows/utils.ts +++ b/apps/studio/data/table-rows/utils.ts @@ -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 }): { diff --git a/packages/pg-meta/src/query/table-row-query.ts b/packages/pg-meta/src/query/table-row-query.ts index a305acf60cd..f9e101a2e2d 100644 --- a/packages/pg-meta/src/query/table-row-query.ts +++ b/packages/pg-meta/src/query/table-row-query.ts @@ -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, @@ -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. diff --git a/packages/pg-meta/test/query/table-row-query.test.ts b/packages/pg-meta/test/query/table-row-query.test.ts index caab0e86933..27d62a906f4 100644 --- a/packages/pg-meta/test/query/table-row-query.test.ts +++ b/packages/pg-meta/test/query/table-row-query.test.ts @@ -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(`