From 91861c4a1ffd75e5ea7fdf255f852730bacaf89b Mon Sep 17 00:00:00 2001
From: Gildas Garcia <1122076+djhi@users.noreply.github.com>
Date: Thu, 18 Jun 2026 09:11:12 +0200
Subject: [PATCH] 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
## 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.
---
.../Functions/FunctionsList/FunctionList.tsx | 23 ++--
.../FunctionsList/FunctionList.utils.test.ts | 103 +++++++++++++++++-
.../FunctionsList/FunctionList.utils.ts | 47 ++++++++
apps/studio/package.json | 1 +
pnpm-lock.yaml | 3 +
5 files changed, 160 insertions(+), 17 deletions(-)
diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx
index 20e9822aecd..654b6a08e9b 100644
--- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx
+++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx
@@ -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(
diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.test.ts b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.test.ts
index 9707df20ad9..0d9d007913e 100644
--- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.test.ts
+++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.test.ts
@@ -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]])
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.ts b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.ts
index 028f909fbcd..6471feff158 100644
--- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.ts
+++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.utils.ts
@@ -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())
+}
diff --git a/apps/studio/package.json b/apps/studio/package.json
index a9632ed86f0..ab3291b1c58 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7ba43115820..d3b1e77cd91 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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