diff --git a/supabase/migrations/20230101000000_extension_function_wrappers.sql b/supabase/migrations/20230101000000_extension_function_wrappers.sql new file mode 100644 index 00000000..5caadbe1 --- /dev/null +++ b/supabase/migrations/20230101000000_extension_function_wrappers.sql @@ -0,0 +1,26 @@ +CREATE SCHEMA IF NOT EXISTS extensions; + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; + +CREATE OR REPLACE FUNCTION public.uuid_generate_v4() +RETURNS uuid +LANGUAGE sql +VOLATILE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT extensions.uuid_generate_v4(); +$$; + +CREATE OR REPLACE FUNCTION public.gen_random_bytes(size integer) +RETURNS bytea +LANGUAGE sql +VOLATILE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT extensions.gen_random_bytes(size); +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/extension-wrappers.pg.test.ts b/tests/pg/extension-wrappers.pg.test.ts new file mode 100644 index 00000000..779ded5e --- /dev/null +++ b/tests/pg/extension-wrappers.pg.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' + +/** + * Covers 20230101000000_extension_function_wrappers: + * + * - Wrapper existence: public.uuid_generate_v4() and + * public.gen_random_bytes() are present and resolve correctly. + * + * - Delegation: public.uuid_generate_v4() → extensions.uuid_generate_v4() + * and public.gen_random_bytes() → extensions.gen_random_bytes(). + * + * - Correctness: uuid_generate_v4() produces valid non-null UUIDs with + * distinct values across calls. gen_random_bytes(n) returns a bytea of + * exactly n bytes; gen_random_bytes(0) returns empty bytea. + * + * - Function properties: VOLATILE, PARALLEL SAFE, SET search_path = '', + * LANGUAGE sql — consistent with the rest of the codebase. + */ + +describe('extension function wrappers', () => { + it('uuid_generate_v4() exists in public schema and returns a valid UUID', async () => { + const pool = getPool() + const r = await pool.query<{ uuid: string }>( + `SELECT public.uuid_generate_v4() AS uuid`, + ) + const uuid = r.rows[0]!.uuid + expect(uuid).toBeTruthy() + // UUID v4 format: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ) + }) + + it('uuid_generate_v4() returns distinct values across calls (no caching)', async () => { + const pool = getPool() + const r = await pool.query<{ uuid: string }>( + `SELECT public.uuid_generate_v4() AS uuid + UNION ALL + SELECT public.uuid_generate_v4()`, + ) + expect(r.rows).toHaveLength(2) + expect(r.rows[0]!.uuid).not.toBe(r.rows[1]!.uuid) + }) + + it('uuid_generate_v4() can be used as a column DEFAULT expression', async () => { + const pool = getPool() + // Create a temp table using the wrapper as default, insert a row, check + // the UUID was generated and is valid. + await pool.query(` + CREATE TEMP TABLE _test_uuid_default ( + id uuid PRIMARY KEY DEFAULT public.uuid_generate_v4(), + label text + ) + `) + await pool.query(`INSERT INTO _test_uuid_default (label) VALUES ('test')`) + const r = await pool.query<{ id: string }>(`SELECT id FROM _test_uuid_default`) + expect(r.rows[0]!.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ) + await pool.query(`DROP TABLE _test_uuid_default`) + }) + + it('gen_random_bytes() exists in public schema', async () => { + const pool = getPool() + const r = await pool.query<{ ok: boolean }>( + `SELECT pg_get_functiondef('public.gen_random_bytes(integer)'::regprocedure) IS NOT NULL AS ok`, + ) + expect(r.rows[0]!.ok).toBe(true) + }) + + it('gen_random_bytes(n) returns bytea of exactly n bytes', async () => { + const pool = getPool() + for (const n of [1, 16, 32, 64]) { + const r = await pool.query<{ len: number }>( + `SELECT octet_length(public.gen_random_bytes($1)) AS len`, + [n], + ) + expect(r.rows[0]!.len).toBe(n) + } + }) + + it('gen_random_bytes(0) returns empty bytea', async () => { + const pool = getPool() + const r = await pool.query<{ len: number }>( + `SELECT octet_length(public.gen_random_bytes(0)) AS len`, + ) + expect(r.rows[0]!.len).toBe(0) + }) + + it('gen_random_bytes returns distinct values across calls', async () => { + const pool = getPool() + const r = await pool.query<{ a: Buffer; b: Buffer }>( + `SELECT public.gen_random_bytes(16) AS a, public.gen_random_bytes(16) AS b`, + ) + expect(r.rows[0]!.a).not.toEqual(r.rows[0]!.b) + }) + + it('uuid_generate_v4() is VOLATILE and PARALLEL SAFE', async () => { + const pool = getPool() + const r = await pool.query<{ + provolatile: string + proparallel: string + }>( + `SELECT provolatile, proparallel + FROM pg_proc + WHERE oid = 'public.uuid_generate_v4()'::regprocedure`, + ) + expect(r.rows[0]!.provolatile).toBe('v') + expect(r.rows[0]!.proparallel).toBe('s') + }) + + it('gen_random_bytes(integer) is VOLATILE and PARALLEL SAFE', async () => { + const pool = getPool() + const r = await pool.query<{ + provolatile: string + proparallel: string + }>( + `SELECT provolatile, proparallel + FROM pg_proc + WHERE oid = 'public.gen_random_bytes(integer)'::regprocedure`, + ) + expect(r.rows[0]!.provolatile).toBe('v') + expect(r.rows[0]!.proparallel).toBe('s') + }) + + it('both wrappers delegate to extensions schema functions', async () => { + const pool = getPool() + // Verify the extensions schema exists and has the source functions. + const extFns = await pool.query<{ name: string; schema: string }>( + `SELECT p.proname AS name, n.nspname AS schema + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'extensions' + AND p.proname IN ('uuid_generate_v4', 'gen_random_bytes')`, + ) + expect(extFns.rows).toHaveLength(2) + + // Verify the public wrappers exist. + const pubFns = await pool.query<{ name: string; schema: string }>( + `SELECT p.proname AS name, n.nspname AS schema + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname IN ('uuid_generate_v4', 'gen_random_bytes')`, + ) + expect(pubFns.rows).toHaveLength(2) + }) + + it('wrappers are defined in SQL (not plpgsql or C)', async () => { + const pool = getPool() + const r = await pool.query<{ proname: string; prolang: string }>( + `SELECT proname, l.lanname AS prolang + FROM pg_proc p + JOIN pg_language l ON l.oid = p.prolang + WHERE p.proname IN ('uuid_generate_v4', 'gen_random_bytes') + AND p.pronamespace = 'public'::regnamespace`, + ) + for (const row of r.rows) { + expect(row.prolang).toBe('sql') + } + }) + + it('wrappers are idempotent — CREATE OR REPLACE succeeds on re-run', async () => { + const pool = getPool() + // Simulate re-applying the migration: re-running the same CREATE OR REPLACE + // should succeed without errors and the functions should still work. + await pool.query(` + CREATE OR REPLACE FUNCTION public.uuid_generate_v4() + RETURNS uuid + LANGUAGE sql + VOLATILE + PARALLEL SAFE + SET search_path = '' + AS $$ SELECT extensions.uuid_generate_v4(); $$ + `) + await pool.query(` + CREATE OR REPLACE FUNCTION public.gen_random_bytes(size integer) + RETURNS bytea + LANGUAGE sql + VOLATILE + PARALLEL SAFE + SET search_path = '' + AS $$ SELECT extensions.gen_random_bytes(size); $$ + `) + // Functions must still work after re-creation. + const r = await pool.query<{ uuid: string; bytes_len: number }>( + `SELECT public.uuid_generate_v4() AS uuid, + octet_length(public.gen_random_bytes(32)) AS bytes_len`, + ) + expect(r.rows[0]!.uuid).toBeTruthy() + expect(r.rows[0]!.bytes_len).toBe(32) + }) +})