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