feat: allow to filter function by code (#46743)

## Problem

It's hard to find a function that references another database entity:
users have to open each of them and look for matches themselves.

## Solution

Add a search input dedicated to function content filtering. Reusing the
existing input to match both names and content may be worse than before
as it would match too many functions if some of them have common sql
keywords in their name.

## Screenshots

<img width="2908" height="672" alt="image"
src="https://github.com/user-attachments/assets/38e35512-d733-434e-8b44-6ff043c01c7e"
/>

<img width="2904" height="560" alt="image"
src="https://github.com/user-attachments/assets/36643865-a1c8-4943-8f13-00272e44eea1"
/>


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

* **New Features**
* Search now performs fuzzy matching over function names and bodies,
ranks exact-name matches higher, and respects
schema/return-type/security filters via centralized filtering logic.
* **Style / UI**
  * Search input placeholder updated to "Search for a function by name".
* **Documentation / Messaging**
* Empty-state messaging clarified to distinguish no functions vs. no
search matches.
* **Tests**
  * Added tests covering the new filtering and ranking behavior.
* **Chores**
  * Added runtime dependency for fuzzy-search library.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-06-18 09:11:12 +02:00
1 parent 37fcfce07c
commit 91861c4a1f
5 files changed
+160 -17

No files matched your search

@@ -1,9 +1,10 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { includes, noop, sortBy } from 'lodash'
import { noop } from 'lodash'
import { Copy, Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import {
Button,
cn,
@@ -17,7 +18,7 @@ import {
} from 'ui'
import { stripInArgModePrefixes } from '../Functions.utils'
import { getDatabaseTriggersHref } from './FunctionList.utils'
import { getDatabaseTriggersHref, getFilteredFunctions } from './FunctionList.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import {
@@ -62,20 +63,10 @@ export const FunctionList = ({
schema,
})
const filteredFunctions = (functions ?? []).filter((x) => {
const matchesName = includes(x.name.toLowerCase(), filterString.toLowerCase())
const matchesReturnType =
returnTypeFilter.length === 0 || returnTypeFilter.includes(x.return_type)
const matchesSecurity =
securityFilter.length === 0 ||
(securityFilter.includes('definer') && x.security_definer) ||
(securityFilter.includes('invoker') && !x.security_definer)
return matchesName && matchesReturnType && matchesSecurity
})
const _functions = sortBy(
filteredFunctions.filter((x) => x.schema == schema),
(func) => func.name.toLocaleLowerCase()
const _functions = useMemo(
() =>
getFilteredFunctions({ functions, filterString, returnTypeFilter, schema, securityFilter }),
[functions, filterString, returnTypeFilter, schema, securityFilter]
)
const { can: canUpdateFunctions } = useAsyncCheckPermissions(
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getDatabaseTriggersHref } from './FunctionList.utils'
import { getDatabaseTriggersHref, getFilteredFunctions } from './FunctionList.utils'
describe('FunctionList.utils: getDatabaseTriggersHref', () => {
it('builds the triggers href with a plain function name', () => {
@@ -28,3 +28,104 @@ describe('FunctionList.utils: getDatabaseTriggersHref', () => {
)
})
})
describe('FunctionList.utils: getFilteredFunctions', () => {
it('returns all functions when filterString is empty', () => {
const functions = [
{ name: 'func1', return_type: 'void', security_definer: false, schema: 'public' },
{ name: 'func2', return_type: 'int', security_definer: true, schema: 'public' },
]
const filtered = getFilteredFunctions({
// @ts-expect-error We don't provide all SavedDatabaseFunction properties
functions,
filterString: '',
returnTypeFilter: [],
schema: 'public',
securityFilter: [],
})
expect(filtered).toEqual(functions)
})
it('filters functions by name when filterString is not empty', () => {
const functions = [
{ name: 'func_test', return_type: 'void', security_definer: false, schema: 'public' },
{ name: 'another', return_type: 'int', security_definer: true, schema: 'public' },
]
const filtered = getFilteredFunctions({
// @ts-expect-error We don't provide all SavedDatabaseFunction properties
functions,
filterString: 'test',
returnTypeFilter: [],
schema: 'public',
securityFilter: [],
})
expect(filtered).toEqual([functions[0]])
})
it('filters functions by name or definition when filterString is not empty ranking by name first', () => {
const functions = [
{
name: 'another',
return_type: 'int',
security_definer: true,
schema: 'public',
definition: "select 'test'",
},
{
name: 'func_test',
return_type: 'void',
security_definer: false,
schema: 'public',
definition: 'whatever',
},
{
name: 'func2',
return_type: 'int',
security_definer: true,
schema: 'public',
definition: 'whatever',
},
]
const filtered = getFilteredFunctions({
// @ts-expect-error We don't provide all SavedDatabaseFunction properties
functions,
filterString: 'test',
returnTypeFilter: [],
schema: 'public',
securityFilter: [],
})
expect(filtered).toEqual([functions[1], functions[0]])
})
it('filters functions by return type when returnTypeFilter is not empty', () => {
const functions = [
{ name: 'func1', return_type: 'void', security_definer: false, schema: 'public' },
{ name: 'func2', return_type: 'int', security_definer: true, schema: 'public' },
]
const filtered = getFilteredFunctions({
// @ts-expect-error We don't provide all SavedDatabaseFunction properties
functions,
filterString: '',
returnTypeFilter: ['int'],
schema: 'public',
securityFilter: [],
})
expect(filtered).toEqual([functions[1]])
})
it('filters functions by security when securityFilter is not empty', () => {
const functions = [
{ name: 'func1', return_type: 'void', security_definer: false, schema: 'public' },
{ name: 'func2', return_type: 'int', security_definer: true, schema: 'public' },
]
const filtered = getFilteredFunctions({
// @ts-expect-error We don't provide all SavedDatabaseFunction properties
functions,
filterString: '',
returnTypeFilter: [],
schema: 'public',
securityFilter: ['invoker'],
})
expect(filtered).toEqual([functions[0]])
})
})
@@ -1,6 +1,53 @@
import Fuse from 'fuse.js'
import { sortBy } from 'lodash'
import { SavedDatabaseFunction } from '@/data/database-functions/database-functions-query'
export const getDatabaseTriggersHref = (
projectRef: string | null | undefined,
name: string | null | undefined
): string => {
return `/project/${projectRef ?? ''}/database/triggers?search=${encodeURIComponent(name ?? '')}`
}
export const getFilteredFunctions = ({
functions,
filterString,
returnTypeFilter,
schema,
securityFilter,
}: {
functions: SavedDatabaseFunction[] | undefined
filterString: string | undefined
returnTypeFilter: string[] | undefined
schema: string | undefined
securityFilter: string[] | undefined
}): SavedDatabaseFunction[] => {
const filteredFunctions = (functions ?? []).filter((x) => {
const matchesReturnType =
returnTypeFilter == null ||
returnTypeFilter.length === 0 ||
returnTypeFilter.includes(x.return_type)
const matchesSecurity =
securityFilter == null ||
securityFilter.length === 0 ||
(securityFilter.includes('definer') && x.security_definer) ||
(securityFilter.includes('invoker') && !x.security_definer)
const matchesSchema = schema == null || x.schema == schema
return matchesReturnType && matchesSecurity && matchesSchema
})
if (filterString) {
// No need to sort like at L93 if filtering is active, fuse will do it by relevance
const fuse = new Fuse(filteredFunctions, {
keys: [
{ name: 'name', weight: 2.0 },
{ name: 'definition', weight: 1.0 },
],
threshold: 0.4,
})
return fuse.search(filterString).map(({ item }) => item)
}
return sortBy(filteredFunctions, (func) => func.name.toLocaleLowerCase())
}
+1
View File
@@ -89,6 +89,7 @@
"dev-tools": "workspace:*",
"file-saver": "^2.0.5",
"framer-motion": "^11.18.2",
"fuse.js": "^7.4.0",
"generate-password-browser": "^1.1.0",
"graphiql": "^5.2.2",
"html-to-image": "^1.11.13",
+3
View File
@@ -1030,6 +1030,9 @@ importers:
framer-motion:
specifier: ^11.18.2
version: 11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
fuse.js:
specifier: ^7.4.0
version: 7.4.0
generate-password-browser:
specifier: ^1.1.0
version: 1.1.0