mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
37fcfce07c
commit
91861c4a1f
5 files changed
+160
-17
No files matched your search
+7
-16
@@ -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(
|
||||
|
||||
+102
-1
@@ -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]])
|
||||
})
|
||||
})
|
||||
+47
@@ -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())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user