mirror of
https://github.com/supabase/supabase.git
synced 2026-06-02 19:02:06 +08:00
## Summary
Third PR in the SafeSql migration stack. Flips the input/output types on
`pgMeta.functions/policies/triggers`'s `.create/.update/.remove` to use
`SafeSqlFragment`, and updates every Studio consumer atomically.
### pg-meta
- `pgMeta.functions/policies/triggers` `.create/.update/.remove` now
return `{ sql: SafeSqlFragment }` and accept branded input parameters
(`PGFunctionCreate`, `PGSavedFunction`, `PolicyCreate/UpdateParams`,
`PGTriggerCreate` with branded condition).
- `QueryModifier.toSql()` returns `SafeSqlFragment`.
### Studio consumers updated to the new branded API
- `data/database-functions/*` (query, create/update/delete mutations)
- `data/database-policies/*` (create, update mutations)
- `data/database-triggers/database-trigger-update-transaction-mutation`
- `components/Database/Triggers/TriggerSheet`
- `components/Database/Functions/CreateFunction`
- `components/Auth/Policies/PolicyEditorPanel`
These consumers land atomically with the pg-meta API change because the
input-type strictness flip (string → `SafeSqlFragment` for SQL fields)
forces every call site to update together.
## Stack
- 1/7: #45897 (merged)
- 2/7: #45903 (merged)
- 3/7: this PR
- 4/7–7/7: upcoming
## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm --filter @supabase/pg-meta test` passes
- [x] Dev-server smoke test: function editor, policy editor, trigger
sheet
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Refactor**
* Strengthened SQL safety across policy, function, and trigger workflows
by converting raw SQL strings to typed SQL fragments and safer
composition
* Updated editor behavior to handle policy conditions/checks as typed
SQL fragments with improved initialization and template handling
* Aligned query and modifier interfaces to return typed SQL fragments
for safer composition
* **Tests**
* Updated tests to use typed SQL fragments and synchronous builders
where applicable
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45990)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
366 lines
9.6 KiB
TypeScript
366 lines
9.6 KiB
TypeScript
import { afterAll, beforeAll, expect, test } from 'vitest'
|
|
|
|
import pgMeta, { safeSql } from '../src/index'
|
|
import { cleanupRoot, createTestDatabase } from './db/utils'
|
|
|
|
beforeAll(async () => {
|
|
// Any global setup if needed
|
|
})
|
|
|
|
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('retrieve, create, update, delete', async ({ executeQuery }) => {
|
|
// Create trigger
|
|
const { sql: createSql } = pgMeta.triggers.create({
|
|
name: 'test_trigger',
|
|
schema: 'public',
|
|
table: 'users_audit',
|
|
function_schema: 'public',
|
|
function_name: 'audit_action',
|
|
function_args: ['test1', 'test2'],
|
|
activation: 'AFTER',
|
|
events: ['UPDATE'],
|
|
orientation: 'ROW',
|
|
condition: safeSql`(old.* IS DISTINCT FROM new.*)`,
|
|
})
|
|
await executeQuery(createSql)
|
|
|
|
const { sql: listTriggers, zod: listZod } = pgMeta.triggers.list()
|
|
const listedTriggers = await listZod.parse(await executeQuery(listTriggers))
|
|
const createdTriggers = listedTriggers.find(
|
|
(t) => t.name === 'test_trigger' && t.table === 'users_audit' && t.schema === 'public'
|
|
)
|
|
expect(createdTriggers!).toMatchInlineSnapshot(
|
|
{ id: expect.any(Number), table_id: expect.any(Number) },
|
|
`
|
|
{
|
|
"activation": "AFTER",
|
|
"condition": "(old.* IS DISTINCT FROM new.*)",
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"UPDATE",
|
|
],
|
|
"function_args": [
|
|
"test1",
|
|
"test2",
|
|
],
|
|
"function_name": "audit_action",
|
|
"function_schema": "public",
|
|
"id": Any<Number>,
|
|
"name": "test_trigger",
|
|
"orientation": "ROW",
|
|
"schema": "public",
|
|
"table": "users_audit",
|
|
"table_id": Any<Number>,
|
|
}
|
|
`
|
|
)
|
|
|
|
// Retrieve created trigger by name
|
|
const { sql: retrieveSqlByName, zod: retrieveZod } = pgMeta.triggers.retrieve({
|
|
name: 'test_trigger',
|
|
table: 'users_audit',
|
|
schema: 'public',
|
|
})
|
|
const trigger = retrieveZod.parse((await executeQuery(retrieveSqlByName))[0])
|
|
expect(trigger!).toMatchInlineSnapshot(
|
|
{ id: expect.any(Number), table_id: expect.any(Number) },
|
|
`
|
|
{
|
|
"activation": "AFTER",
|
|
"condition": "(old.* IS DISTINCT FROM new.*)",
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"UPDATE",
|
|
],
|
|
"function_args": [
|
|
"test1",
|
|
"test2",
|
|
],
|
|
"function_name": "audit_action",
|
|
"function_schema": "public",
|
|
"id": Any<Number>,
|
|
"name": "test_trigger",
|
|
"orientation": "ROW",
|
|
"schema": "public",
|
|
"table": "users_audit",
|
|
"table_id": Any<Number>,
|
|
}
|
|
`
|
|
)
|
|
|
|
// Retrieve created trigger by id
|
|
const { sql: retrieveSqlById } = pgMeta.triggers.retrieve({
|
|
id: trigger!.id,
|
|
})
|
|
const triggerById = retrieveZod.parse((await executeQuery(retrieveSqlById))[0])
|
|
expect(triggerById!).toMatchInlineSnapshot(
|
|
{ id: expect.any(Number), table_id: expect.any(Number) },
|
|
`
|
|
{
|
|
"activation": "AFTER",
|
|
"condition": "(old.* IS DISTINCT FROM new.*)",
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"UPDATE",
|
|
],
|
|
"function_args": [
|
|
"test1",
|
|
"test2",
|
|
],
|
|
"function_name": "audit_action",
|
|
"function_schema": "public",
|
|
"id": Any<Number>,
|
|
"name": "test_trigger",
|
|
"orientation": "ROW",
|
|
"schema": "public",
|
|
"table": "users_audit",
|
|
"table_id": Any<Number>,
|
|
}
|
|
`
|
|
)
|
|
|
|
// Update trigger
|
|
const { sql: updateSql } = pgMeta.triggers.update(trigger!, {
|
|
name: 'test_trigger_renamed',
|
|
enabled_mode: 'DISABLED',
|
|
})
|
|
await executeQuery(updateSql)
|
|
|
|
// Verify update
|
|
const { sql: retrieveUpdatedSql } = pgMeta.triggers.retrieve({
|
|
id: trigger!.id,
|
|
})
|
|
const updatedTrigger = retrieveZod.parse((await executeQuery(retrieveUpdatedSql))[0])
|
|
expect(updatedTrigger).toMatchInlineSnapshot(
|
|
{ id: expect.any(Number), table_id: expect.any(Number) },
|
|
`
|
|
{
|
|
"activation": "AFTER",
|
|
"condition": "(old.* IS DISTINCT FROM new.*)",
|
|
"enabled_mode": "DISABLED",
|
|
"events": [
|
|
"UPDATE",
|
|
],
|
|
"function_args": [
|
|
"test1",
|
|
"test2",
|
|
],
|
|
"function_name": "audit_action",
|
|
"function_schema": "public",
|
|
"id": Any<Number>,
|
|
"name": "test_trigger_renamed",
|
|
"orientation": "ROW",
|
|
"schema": "public",
|
|
"table": "users_audit",
|
|
"table_id": Any<Number>,
|
|
}
|
|
`
|
|
)
|
|
|
|
// Update trigger again
|
|
const { sql: updateSql2 } = pgMeta.triggers.update(updatedTrigger!, {
|
|
enabled_mode: 'REPLICA',
|
|
})
|
|
await executeQuery(updateSql2)
|
|
|
|
// Verify second update
|
|
const { sql: retrieveUpdated2Sql } = pgMeta.triggers.retrieve({
|
|
name: 'test_trigger_renamed',
|
|
table: 'users_audit',
|
|
schema: 'public',
|
|
})
|
|
const updatedTrigger2 = retrieveZod.parse((await executeQuery(retrieveUpdated2Sql))[0])
|
|
expect(updatedTrigger2).toMatchObject({
|
|
enabled_mode: 'REPLICA',
|
|
})
|
|
|
|
// Remove trigger
|
|
const { sql: removeSql } = pgMeta.triggers.remove(updatedTrigger2!)
|
|
await executeQuery(removeSql)
|
|
|
|
// Verify removal
|
|
const { sql: verifyRemoveSql } = pgMeta.triggers.retrieve({
|
|
name: 'test_trigger_renamed',
|
|
table: 'users_audit',
|
|
schema: 'public',
|
|
})
|
|
const result = retrieveZod.parse((await executeQuery(verifyRemoveSql))[0])
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
withTestDatabase('multi event', async ({ executeQuery }) => {
|
|
// Create trigger
|
|
const { sql: createSql } = pgMeta.triggers.create({
|
|
name: 'test_multi_event_trigger',
|
|
schema: 'public',
|
|
table: 'users_audit',
|
|
function_schema: 'public',
|
|
function_name: 'audit_action',
|
|
function_args: ['test1', 'test2'],
|
|
activation: 'AFTER',
|
|
events: ['insert', 'update', 'delete'],
|
|
orientation: 'ROW',
|
|
})
|
|
await executeQuery(createSql)
|
|
|
|
// Verify created trigger
|
|
const { sql: retrieveSql, zod: retrieveZod } = pgMeta.triggers.retrieve({
|
|
name: 'test_multi_event_trigger',
|
|
table: 'users_audit',
|
|
schema: 'public',
|
|
})
|
|
const trigger = retrieveZod.parse((await executeQuery(retrieveSql))[0])
|
|
expect(trigger).toMatchInlineSnapshot(
|
|
{ id: expect.any(Number), table_id: expect.any(Number) },
|
|
`
|
|
{
|
|
"activation": "AFTER",
|
|
"condition": null,
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"INSERT",
|
|
"DELETE",
|
|
"UPDATE",
|
|
],
|
|
"function_args": [
|
|
"test1",
|
|
"test2",
|
|
],
|
|
"function_name": "audit_action",
|
|
"function_schema": "public",
|
|
"id": Any<Number>,
|
|
"name": "test_multi_event_trigger",
|
|
"orientation": "ROW",
|
|
"schema": "public",
|
|
"table": "users_audit",
|
|
"table_id": Any<Number>,
|
|
}
|
|
`
|
|
)
|
|
|
|
// Remove trigger
|
|
const { sql: removeSql } = pgMeta.triggers.remove(trigger!)
|
|
await executeQuery(removeSql)
|
|
|
|
// Verify removal
|
|
const { sql: verifyRemoveSql } = pgMeta.triggers.retrieve({
|
|
name: 'test_multi_event_trigger',
|
|
table: 'users_audit',
|
|
schema: 'public',
|
|
})
|
|
const result = retrieveZod.parse((await executeQuery(verifyRemoveSql))[0])
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
withTestDatabase('triggers with the same name on different schemas', async ({ executeQuery }) => {
|
|
// Create test schemas and triggers
|
|
await executeQuery(`
|
|
create function tr_f() returns trigger language plpgsql as 'begin end';
|
|
create schema s1; create table s1.t(); create trigger tr before insert on s1.t execute function tr_f();
|
|
create schema s2; create table s2.t(); create trigger tr before insert on s2.t execute function tr_f();
|
|
`)
|
|
|
|
// List and verify triggers
|
|
const { sql: listSql, zod: listZod } = pgMeta.triggers.list()
|
|
const triggers = listZod.parse(await executeQuery(listSql))
|
|
expect(triggers.map(({ id, table_id, ...trigger }) => trigger)).toMatchInlineSnapshot(`
|
|
[
|
|
{
|
|
"activation": "BEFORE",
|
|
"condition": null,
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"INSERT",
|
|
],
|
|
"function_args": [],
|
|
"function_name": "tr_f",
|
|
"function_schema": "public",
|
|
"name": "tr",
|
|
"orientation": "STATEMENT",
|
|
"schema": "s1",
|
|
"table": "t",
|
|
},
|
|
{
|
|
"activation": "BEFORE",
|
|
"condition": null,
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"INSERT",
|
|
],
|
|
"function_args": [],
|
|
"function_name": "tr_f",
|
|
"function_schema": "public",
|
|
"name": "tr",
|
|
"orientation": "STATEMENT",
|
|
"schema": "s2",
|
|
"table": "t",
|
|
},
|
|
]
|
|
`)
|
|
})
|
|
|
|
withTestDatabase('triggers on capitalized schema and table names', async ({ executeQuery }) => {
|
|
// Create test schema and trigger
|
|
await executeQuery(`
|
|
CREATE SCHEMA "MySchema";
|
|
CREATE TABLE "MySchema"."MyTable" (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP
|
|
);
|
|
CREATE OR REPLACE FUNCTION "MySchema"."my_trigger_function"()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at := CURRENT_TIMESTAMP;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER "my_trigger"
|
|
BEFORE INSERT ON "MySchema"."MyTable"
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION "MySchema"."my_trigger_function"();
|
|
`)
|
|
|
|
// List and verify triggers
|
|
const { sql: listSql, zod: listZod } = pgMeta.triggers.list()
|
|
const triggers = listZod.parse(await executeQuery(listSql))
|
|
expect(triggers.map(({ id, table_id, ...trigger }) => trigger)).toMatchInlineSnapshot(`
|
|
[
|
|
{
|
|
"activation": "BEFORE",
|
|
"condition": null,
|
|
"enabled_mode": "ORIGIN",
|
|
"events": [
|
|
"INSERT",
|
|
],
|
|
"function_args": [],
|
|
"function_name": "my_trigger_function",
|
|
"function_schema": "MySchema",
|
|
"name": "my_trigger",
|
|
"orientation": "ROW",
|
|
"schema": "MySchema",
|
|
"table": "MyTable",
|
|
},
|
|
]
|
|
`)
|
|
})
|