Files
supabase/packages/pg-meta/test/functions.test.ts
Joshen Lim 097f220c5c Add support for managing stored procedures under database functions (#46977)
## Context

Dashboard currently doesn't have any support for managing stored
procedures. In the event that the security advisor surfaces a warning
about a stored procedure, users hence run into a dead-end as there's
currently no way to self-remediate via the dashboard

## Changes involved

We're hence adding support for managing stored procedures within
Database Functions
<img width="1082" height="546" alt="image"
src="https://github.com/user-attachments/assets/2598a5fe-e58f-4e8a-ad2f-9cb6d0eb2f53"
/>

Creating a function now shows a dropdown to select the type
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/acc9249d-7b25-4416-aae8-89c630e1c62b"
/>

In which if stored procedure is selected, the following fields will be
hidden since they're irrelevant for stored procedures
- Return type
- Behaviour (Under advanced settings)

Some other minor UI changes as well:
- Field inputs are re-ordered a little, opting to group "Schema" and
"Name" into one section, followed by "Type" and "Return type"
- Opting to show "Return type" when editing a function but disabled
- Add schema filter for fetching database functions to reduce
unnecessary load on the database

## To test
- [ ] Can create, update, delete, read stored procedures via database
functions page

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

## Summary

- **New Features**
- Added PostgreSQL **procedure** support alongside functions, including
a **Type** selector in the create/edit flow.
- Updated Functions UI with a new **Type** column and procedure-aware
return/argument details.

- **Improvements**
- Refreshed create/edit headers and language help text for clearer
context.
- Improved argument parsing/display, including better handling of
procedure argument modes.

- **Bug Fixes**
- Corrected routine-type handling during function/procedure delete and
update SQL operations.

- **Tests**
- Updated unit snapshots and end-to-end UI flows/labels for the new “New
function” control.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-17 19:15:54 +08:00

383 lines
11 KiB
TypeScript

import { afterAll, expect, test } from 'vitest'
import pgMeta, { safeSql } from '../src/index'
import type { PGFunction, PGSavedFunction } from '../src/pg-meta-functions'
import { cleanupRoot, createTestDatabase } from './db/utils'
// Test fixtures originate from `executeQuery` results that match the
// API/database boundary; brand the raw-SQL fields so they satisfy
// `update`/`remove` parameter types.
const asSavedFunction = (fn: PGFunction): PGSavedFunction => fn as unknown as PGSavedFunction
afterAll(async () => {
await cleanupRoot()
})
const withTestDatabase = (
name: string,
fn: (db: Awaited<ReturnType<typeof createTestDatabase>>) => Promise<void>
) => {
test(name, async () => {
const db = await createTestDatabase()
try {
await fn(db)
} finally {
await db.cleanup()
}
})
}
withTestDatabase('list functions', async ({ executeQuery }) => {
const { sql, zod } = pgMeta.functions.list()
const res = zod.parse(await executeQuery(sql))
// Test for the 'add' function created in init.sql
const addFunction = res.find(({ name }) => name === 'add')
expect(addFunction).toMatchInlineSnapshot(
{ id: expect.any(Number) },
`
{
"args": [
{
"has_default": false,
"mode": "in",
"name": "",
"type_id": 23,
},
{
"has_default": false,
"mode": "in",
"name": "",
"type_id": 23,
},
],
"argument_types": "integer, integer",
"behavior": "IMMUTABLE",
"complete_statement": "CREATE OR REPLACE FUNCTION public.add(integer, integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE STRICT
AS $function$select $1 + $2;$function$
",
"config_params": null,
"definition": "select $1 + $2;",
"id": Any<Number>,
"identity_argument_types": "integer, integer",
"is_set_returning_function": false,
"language": "sql",
"name": "add",
"return_type": "integer",
"return_type_id": 23,
"return_type_relation_id": null,
"schema": "public",
"security_definer": false,
"type": "function",
}
`
)
})
withTestDatabase('list functions with included schemas', async ({ executeQuery }) => {
const { sql, zod } = pgMeta.functions.list({
includedSchemas: ['public'],
})
const res = zod.parse(await executeQuery(sql))
expect(res.length).toBeGreaterThan(0)
res.forEach((func) => {
expect(func.schema).toBe('public')
})
})
withTestDatabase('list functions with excluded schemas', async ({ executeQuery }) => {
const { sql, zod } = pgMeta.functions.list({
excludedSchemas: ['public'],
})
const res = zod.parse(await executeQuery(sql))
res.forEach((func) => {
expect(func.schema).not.toBe('public')
})
})
withTestDatabase(
'list functions with excluded schemas and include System Schemas',
async ({ executeQuery }) => {
const { sql, zod } = pgMeta.functions.list({
excludedSchemas: ['public'],
includeSystemSchemas: true,
})
const res = zod.parse(await executeQuery(sql))
expect(res.length).toBeGreaterThan(0)
res.forEach((func) => {
expect(func.schema).not.toBe('public')
})
}
)
withTestDatabase('retrieve, create, update, delete', async ({ executeQuery }) => {
// Create function
const { sql: createSql } = pgMeta.functions.create({
name: 'test_func',
schema: 'public',
args: [safeSql`a int2`, safeSql`b int2`],
definition: 'select a + b',
return_type: safeSql`integer`,
language: 'sql',
behavior: 'STABLE',
security_definer: true,
config_params: { search_path: safeSql`hooks, auth`, role: safeSql`postgres` },
})
await executeQuery(createSql)
// // Retrieve function
const { sql: retrieveSql, zod: retrieveZod } = pgMeta.functions.retrieve({
name: 'test_func',
schema: 'public',
args: ['a int2', 'b int2'],
})
const retrieve = await executeQuery(retrieveSql)
const res = retrieveZod.parse(retrieve[0])
const functionId = res!.id
expect({ data: res, error: null }).toMatchInlineSnapshot(
{ data: { id: expect.any(Number) } },
`
{
"data": {
"args": [
{
"has_default": false,
"mode": "in",
"name": "a",
"type_id": 21,
},
{
"has_default": false,
"mode": "in",
"name": "b",
"type_id": 21,
},
],
"argument_types": "a smallint, b smallint",
"behavior": "STABLE",
"complete_statement": "CREATE OR REPLACE FUNCTION public.test_func(a smallint, b smallint)
RETURNS integer
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'hooks', 'auth'
SET role TO 'postgres'
AS $function$select a + b$function$
",
"config_params": {
"role": "postgres",
"search_path": "hooks, auth",
},
"definition": "select a + b",
"id": Any<Number>,
"identity_argument_types": "a smallint, b smallint",
"is_set_returning_function": false,
"language": "sql",
"name": "test_func",
"return_type": "integer",
"return_type_id": 23,
"return_type_relation_id": null,
"schema": "public",
"security_definer": true,
"type": "function",
},
"error": null,
}
`
)
// create test_schema to move the function into:
const { sql: createSchemaSql } = pgMeta.schemas.create({ name: 'test_schema' })
await executeQuery(createSchemaSql)
const { sql: updateSql } = pgMeta.functions.update(asSavedFunction(res!), {
name: 'test_func_renamed',
schema: 'test_schema',
definition: 'select b - a',
})
await executeQuery(updateSql)
const { sql: retrieveRenamedSql } = pgMeta.functions.retrieve({ id: functionId })
const retrieveRenamed = await executeQuery(retrieveRenamedSql)
const resUpdated = retrieveZod.parse(retrieveRenamed[0])
expect({ data: resUpdated, error: null }).toMatchInlineSnapshot(
{ data: { id: expect.any(Number) } },
`
{
"data": {
"args": [
{
"has_default": false,
"mode": "in",
"name": "a",
"type_id": 21,
},
{
"has_default": false,
"mode": "in",
"name": "b",
"type_id": 21,
},
],
"argument_types": "a smallint, b smallint",
"behavior": "STABLE",
"complete_statement": "CREATE OR REPLACE FUNCTION test_schema.test_func_renamed(a smallint, b smallint)
RETURNS integer
LANGUAGE sql
STABLE SECURITY DEFINER
SET role TO 'postgres'
SET search_path TO 'hooks', 'auth'
AS $function$select b - a$function$
",
"config_params": {
"role": "postgres",
"search_path": "hooks, auth",
},
"definition": "select b - a",
"id": Any<Number>,
"identity_argument_types": "a smallint, b smallint",
"is_set_returning_function": false,
"language": "sql",
"name": "test_func_renamed",
"return_type": "integer",
"return_type_id": 23,
"return_type_relation_id": null,
"schema": "test_schema",
"security_definer": true,
"type": "function",
},
"error": null,
}
`
)
// Remove function
const { sql: removeSql } = pgMeta.functions.remove(asSavedFunction(resUpdated!))
await executeQuery(removeSql)
// Verify function is removed
const { sql: verifyRemoveSql } = pgMeta.functions.retrieve({ id: functionId })
const result = await executeQuery(verifyRemoveSql)
expect(result).toHaveLength(0)
})
withTestDatabase('retrieve set-returning function', async ({ executeQuery }) => {
// Retrieve function
const { sql: retrieveSql, zod: retrieveZod } = pgMeta.functions.retrieve({
schema: 'public',
name: 'function_returning_set_of_rows',
args: [],
})
const retrieve = await executeQuery(retrieveSql)
const res = retrieveZod.parse(retrieve[0])
expect(res).toMatchInlineSnapshot(
{
id: expect.any(Number),
return_type_id: expect.any(Number),
return_type_relation_id: expect.any(Number),
},
`
{
"args": [],
"argument_types": "",
"behavior": "STABLE",
"complete_statement": "CREATE OR REPLACE FUNCTION public.function_returning_set_of_rows()
RETURNS SETOF users
LANGUAGE sql
STABLE
AS $function$
select * from public.users;
$function$
",
"config_params": null,
"definition": "
select * from public.users;
",
"id": Any<Number>,
"identity_argument_types": "",
"is_set_returning_function": true,
"language": "sql",
"name": "function_returning_set_of_rows",
"return_type": "SETOF users",
"return_type_id": Any<Number>,
"return_type_relation_id": Any<Number>,
"schema": "public",
"security_definer": false,
"type": "function",
}
`
)
})
withTestDatabase('create function with various config_params values', async ({ executeQuery }) => {
// Set initial application_name for consistent testing
await executeQuery("SET application_name = 'current-app-name'")
const { sql: createSql1 } = pgMeta.functions.create({
name: 'test_func_config_1',
schema: 'public',
definition: 'select 1',
return_type: safeSql`integer`,
language: 'sql',
config_params: {
search_path: safeSql`''`, // Quoted empty string
application_name: 'FROM CURRENT', // Special syntax: SET param FROM CURRENT
work_mem: safeSql`'8MB'`, // Regular syntax: SET param TO value
},
})
await executeQuery(createSql1)
// Verify the function was created correctly
const { sql: retrieveSql1, zod: retrieveZod } = pgMeta.functions.retrieve({
name: 'test_func_config_1',
schema: 'public',
args: [],
})
const result1 = retrieveZod.parse((await executeQuery(retrieveSql1))[0])
expect(result1).toBeDefined()
expect(result1!.config_params).toEqual({
search_path: '""',
application_name: 'current-app-name',
work_mem: '8MB',
})
// Clean up
const { sql: removeSql1 } = pgMeta.functions.remove(asSavedFunction(result1!))
await executeQuery(removeSql1)
})
withTestDatabase(
'create function with namespaced custom GUC config_params',
async ({ executeQuery }) => {
const { sql: createSql } = pgMeta.functions.create({
name: 'test_func_namespaced_guc',
schema: 'public',
definition: 'select 1',
return_type: safeSql`integer`,
language: 'sql',
config_params: {
'app.jwt_secret': safeSql`'top-secret'`,
},
})
await executeQuery(createSql)
const { sql: retrieveSql, zod: retrieveZod } = pgMeta.functions.retrieve({
name: 'test_func_namespaced_guc',
schema: 'public',
args: [],
})
const result = retrieveZod.parse((await executeQuery(retrieveSql))[0])
expect(result).toBeDefined()
expect(result!.config_params).toEqual({
'app.jwt_secret': 'top-secret',
})
const { sql: removeSql } = pgMeta.functions.remove(asSavedFunction(result!))
await executeQuery(removeSql)
}
)