Mutation SQL Requirements - Complete Guide¶
This guide provides the authoritative reference for writing PostgreSQL functions that work with FraiseQL mutations. It covers everything from basic function structure to advanced error handling patterns.
Complete Runnable Example¶
"""
End-to-end example: Creating a user with FraiseQL mutations.
Prerequisites:
- PostgreSQL database
- FraiseQL installed: pip install fraiseql
- Run the SQL setup below first
"""
import asyncio
from uuid import UUID
import fraiseql
# SQL Setup (run this first in your database):
SQL_SETUP = """
-- 1. Create mutation_response type (if not exists)
CREATE TYPE mutation_response AS (
status text,
message text,
entity_id text,
entity_type text,
entity jsonb,
updated_fields jsonb,
cascade jsonb,
metadata jsonb
);
-- 2. Create users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 3. Create mutation function
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
result mutation_response;
new_user users;
BEGIN
-- Validate input
IF input_payload->>'name' IS NULL THEN
result.status := 'validation:invalid_name';
result.message := 'Name is required';
RETURN result;
END IF;
-- Insert user
INSERT INTO users (name, email)
VALUES (
input_payload->>'name',
input_payload->>'email'
)
RETURNING * INTO new_user;
-- Return success
result.status := 'created';
result.message := 'User created successfully';
result.entity_id := new_user.id::text;
result.entity_type := 'User';
result.entity := row_to_json(new_user);
RETURN result;
END;
$$ LANGUAGE plpgsql;
"""
# Python Integration:
@fraiseql.type(sql_source="users")
class User:
id: UUID
name: str
email: str
@fraiseql.mutation
async def create_user(info, name: str, email: str) -> fraiseql.MutationResponse[User]:
"""Create a new user."""
db = info.context["db"]
# Call PostgreSQL function
result = await db.execute(
"SELECT * FROM create_user($1::jsonb)",
fraiseql.json.dumps({"name": name, "email": email})
)
return fraiseql.MutationResponse.from_db(result[0])
async def main():
schema = fraiseql.Schema("postgresql://localhost/mydb")
# Execute mutation
mutation = """
mutation {
createUser(name: "Alice Johnson", email: "alice@example.com") {
status
message
entity {
id
name
email
}
errors {
code
identifier
message
}
}
}
"""
result = await schema.execute(mutation)
if result.errors:
print(f"❌ GraphQL Errors: {result.errors}")
else:
response = result.data['createUser']
if response['status'] == 'created':
print(f"✅ {response['message']}")
print(f" User ID: {response['entity']['id']}")
else:
print(f"⚠️ {response['message']}")
for error in response.get('errors', []):
print(f" - {error['identifier']}: {error['message']}")
if __name__ == "__main__":
asyncio.run(main())
Expected Output (Success):
Expected Output (Validation Error):
Quick Start (90% Use Case)¶
For most mutations, you only need a PostgreSQL function that returns a mutation_response with status strings:
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
result mutation_response;
BEGIN
-- Your business logic here
-- ...
-- Return success
result.status := 'created';
result.message := 'User created successfully';
result.entity_id := NEW.id::text;
result.entity_type := 'User';
result.entity := row_to_json(NEW);
result.updated_fields := NULL;
result.cascade := NULL;
result.metadata := NULL;
RETURN result;
END;
$$ LANGUAGE plpgsql;
This automatically gives you structured errors in GraphQL responses without any special base classes.
How SQL Becomes GraphQL (The Pipeline)¶
Understanding how FraiseQL transforms PostgreSQL responses into GraphQL helps you write effective mutations.
The Transformation Flow¶
PostgreSQL Function Returns mutation_response (8 fields)
↓
Rust Pipeline Parses and Transforms
↓
GraphQL Error Response (root fields + errors array)
Field Mapping: SQL → GraphQL¶
What You Write in SQL:
result.status := 'validation:'; -- Field 1
result.message := 'Name is required'; -- Field 2
result.metadata := NULL; -- Field 8 (or explicit errors)
What GraphQL Client Receives:
{
"code": 422, // ← GENERATED by Rust from status
"status": "validation:", // ← SQL field 1 (passed through)
"message": "Name is required", // ← SQL field 2 (passed through)
"errors": [{ // ← GENERATED by Rust (or from metadata)
"code": 422, // ← Same as root
"identifier": "validation", // ← EXTRACTED from "validation:"
"message": "Name is required",// ← Copied from SQL field 2
"details": null
}]
}
Key Insights¶
You DON'T set in SQL:
- ❌ code - Rust generates this from status string prefix
- ❌ identifier - Rust extracts this from status string after :
- ❌ Root-level errors array - Rust builds this automatically
You DO set in SQL:
- ✅ status - Use format prefix:identifier (e.g., "validation:")
- ✅ message - Human-readable summary
- ✅ metadata.errors - (Optional) For Pattern 2 explicit errors
Pattern 1: Auto-Generation (Simple)¶
SQL:
result.status := 'validation:';
result.message := 'Name is required';
result.metadata := NULL; -- ← No explicit errors
Rust Logic:
1. Sees metadata is NULL
2. Extracts "validation" from "validation:"
3. Auto-generates errors array with single error
Pattern 2: Explicit Errors (Advanced)¶
SQL:
result.status := 'validation:';
result.message := 'Multiple validation errors';
result.metadata := jsonb_build_object(
'errors', jsonb_build_array( -- ← Explicit errors here!
jsonb_build_object(
'code', 422,
'identifier', 'invalid_email',
'message', 'Email format invalid',
'details', jsonb_build_object('field', 'email')
)
)
);
Rust Logic:
1. Sees metadata.errors exists
2. Uses it directly (no auto-generation)
3. Passes through to GraphQL response
Priority: metadata.errors overrides auto-generation.
Error Handling (The FraiseQL Way™)¶
Native Error Arrays¶
All error responses get errors: list[Error] automatically. This is populated from status strings by the Rust pipeline.
mutation {
createUser(input: {name: ""}) {
__typename
message
code
status
errors { # ← Auto-populated!
code
identifier
message
details
}
}
}
Response:
{
"data": {
"createUser": {
"__typename": "CreateUserError",
"message": "Name is required",
"code": 422,
"status": "validation:",
"errors": [{
"code": 422,
"identifier": "validation",
"message": "Name is required",
"details": null
}]
}
}
}
Note: Error responses have both root-level fields (code, message, status) and an errors array by design. Root fields provide quick access for simple cases, while the array enables structured processing for multiple errors. See Error Handling Patterns for detailed explanation.
Pattern 1: Auto-Generated Errors (Recommended)¶
Use status strings like "validation:" or "not_found:user". FraiseQL extracts the identifier and creates structured errors automatically.
PostgreSQL Function:
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
result mutation_response;
user_name text;
BEGIN
user_name := input_payload->>'name';
-- Validation
IF user_name IS NULL OR length(trim(user_name)) = 0 THEN
result.status := 'validation:';
result.message := 'Name is required';
RETURN result;
END IF;
-- Your creation logic here
-- ...
result.status := 'created';
result.message := 'User created successfully';
-- ... other fields
RETURN result;
END;
$$ LANGUAGE plpgsql;
GraphQL Response: Errors array auto-populated from "validation:" → {"identifier": "validation"}.
Pattern 2: Explicit Validation Errors (Advanced)¶
For complex validation with multiple field-level errors, use metadata.errors:
PostgreSQL Function:
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
result mutation_response;
validation_errors jsonb := '[]'::jsonb;
BEGIN
-- Collect multiple validation errors
IF input_payload->>'email' IS NULL THEN
validation_errors := validation_errors || jsonb_build_object(
'code', 422,
'identifier', 'email_required',
'message', 'Email is required',
'details', '{"field": "email"}'
);
END IF;
IF input_payload->>'name' IS NULL THEN
validation_errors := validation_errors || jsonb_build_object(
'code', 422,
'identifier', 'name_required',
'message', 'Name is required',
'details', '{"field": "name"}'
);
END IF;
-- If errors, return them
IF jsonb_array_length(validation_errors) > 0 THEN
result.status := 'validation:';
result.message := 'Validation failed';
result.metadata := jsonb_build_object('errors', validation_errors);
RETURN result;
END IF;
-- Success case...
END;
$$ LANGUAGE plpgsql;
GraphQL Response: Uses explicit errors from metadata.errors instead of auto-generation.
~~Pattern 3: Legacy Format~~ (Deprecated)¶
The old format with success/data/error JSONB fields is deprecated. Use mutation_response type instead.
PostgreSQL Requirements¶
Required: mutation_response Type¶
All mutation functions must return this composite type:
CREATE TYPE mutation_response AS (
status text, -- Required: Status string (success/error/noop)
message text, -- Required: Human-readable message
entity_id text, -- Optional: ID of affected entity
entity_type text, -- Optional: GraphQL type name
entity jsonb, -- Optional: Full entity data
updated_fields text[], -- Optional: Fields that were updated
cascade jsonb, -- Optional: CASCADE data for cache invalidation
metadata jsonb -- Optional: Extra context (explicit errors go here)
);
Required: Helper Functions¶
Use these helper functions for consistent status handling:
-- Success states
SELECT mutation_success('User created'); -- → status: 'success'
SELECT mutation_created('User created'); -- → status: 'created'
SELECT mutation_updated('User updated'); -- → status: 'updated'
SELECT mutation_deleted('User deleted'); -- → status: 'deleted'
-- Error states
SELECT mutation_validation_error('Invalid input'); -- → status: 'validation:'
SELECT mutation_not_found('User not found'); -- → status: 'not_found:user'
SELECT mutation_error('failed:custom', 'Custom error'); -- → status: 'failed:custom'
Input Handling¶
Parameter: Functions receive jsonb input payload:
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
BEGIN
-- Extract fields
DECLARE
user_name text := input_payload->>'name';
user_email text := input_payload->>'email';
BEGIN
-- Use extracted values...
END;
END;
$$ LANGUAGE plpgsql;
Type Casting: Handle type conversion safely:
-- Safe integer conversion
user_age integer := (input_payload->>'age')::integer;
-- Safe boolean with default
is_active boolean := COALESCE((input_payload->>'is_active')::boolean, true);
-- Safe array handling
tags text[] := ARRAY(SELECT jsonb_array_elements_text(input_payload->'tags'));
Output Structure¶
entity field: The created/updated entity data. Use row_to_json() for full rows:
entity_type field: GraphQL __typename mapping:
entity_id field: String representation of the primary key:
updated_fields array: For updates, list changed fields:
cascade field: Cache invalidation data (advanced):
result.cascade := jsonb_build_object(
'updated', jsonb_build_array(
jsonb_build_object('__typename', 'User', 'id', NEW.id)
),
'invalidations', jsonb_build_array('User:stats')
);
metadata field: Extra context, including explicit errors:
result.metadata := jsonb_build_object(
'errors', validation_errors, -- For Pattern 2
'extra_context', 'value' -- Any additional data
);
Status Strings Reference¶
| Status Pattern | HTTP Code | Use Case | Example |
|---|---|---|---|
success |
200 | Generic success | Basic operations |
created |
201 | Resource creation | INSERT operations |
updated |
200 | Resource update | UPDATE operations |
deleted |
200 | Resource deletion | DELETE operations |
validation: |
422 | Input validation | Required fields missing |
failed:conflict |
409 | Business conflicts | Duplicate emails |
failed:forbidden |
403 | Permission denied | Access control |
failed:unauthorized |
401 | Auth required | Missing credentials |
not_found:{type} |
404 | Resource missing | User not found |
timeout:{operation} |
408 | Operation timeout | Database timeout |
noop:{reason} |
422 | No changes made | Already exists |
log_and_return_mutation Pattern¶
For complex functions, use this standardized error logging pattern:
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
result mutation_response;
BEGIN
-- Start transaction if needed
BEGIN
-- Your business logic here
-- ...
-- Success
RETURN mutation_created('User created successfully');
EXCEPTION
WHEN unique_violation THEN
-- Log and return structured error
RETURN log_and_return_mutation(
mutation_error('failed:conflict', 'User already exists'),
'User creation failed due to unique constraint'
);
WHEN OTHERS THEN
-- Log unexpected errors
RETURN log_and_return_mutation(
mutation_error('failed:internal', 'Internal error'),
SQLERRM
);
END;
END;
$$ LANGUAGE plpgsql;
Performance Note: log_and_return_mutation adds logging overhead. Use only for unexpected errors.
Complete Working Examples¶
Example 1: Simple Create¶
PostgreSQL Function:
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
new_user users;
BEGIN
-- Insert user
INSERT INTO users (name, email, created_at)
VALUES (
input_payload->>'name',
input_payload->>'email',
now()
)
RETURNING * INTO new_user;
-- Return success
RETURN mutation_created('User created successfully')
WITH entity_id = new_user.id::text,
entity_type = 'User',
entity = row_to_json(new_user);
END;
$$ LANGUAGE plpgsql;
GraphQL Schema:
@fraiseql.input
class CreateUserInput:
name: str
email: str
@fraiseql.success
class CreateUserSuccess:
user: User
message: str
@fraiseql.failure
class CreateUserError:
message: str
# errors array auto-populated
@fraiseql.mutation
class CreateUser:
input: CreateUserInput
success: CreateUserSuccess
failure: CreateUserError
Example 2: Update with Validation¶
PostgreSQL Function:
CREATE OR REPLACE FUNCTION update_user(user_id text, input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
updated_user users;
changed_fields text[] := ARRAY[]::text[];
BEGIN
-- Check if user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = user_id::uuid) THEN
RETURN mutation_not_found('User not found');
END IF;
-- Build update query dynamically
UPDATE users SET
updated_at = now()
-- Add other fields conditionally
name = CASE WHEN input_payload ? 'name'
THEN input_payload->>'name' ELSE name END,
email = CASE WHEN input_payload ? 'email'
THEN input_payload->>'email' ELSE email END
WHERE id = user_id::uuid
RETURNING * INTO updated_user;
-- Track changed fields
IF input_payload ? 'name' THEN
changed_fields := changed_fields || 'name';
END IF;
IF input_payload ? 'email' THEN
changed_fields := changed_fields || 'email';
END IF;
RETURN mutation_updated('User updated successfully')
WITH entity_id = updated_user.id::text,
entity_type = 'User',
entity = row_to_json(updated_user),
updated_fields = changed_fields;
END;
$$ LANGUAGE plpgsql;
Example 3: Delete with CASCADE¶
PostgreSQL Function:
CREATE OR REPLACE FUNCTION delete_user(user_id text)
RETURNS mutation_response AS $$
DECLARE
deleted_user users;
cascade_data jsonb;
BEGIN
-- Check if user exists
SELECT * INTO deleted_user FROM users WHERE id = user_id::uuid;
IF NOT FOUND THEN
RETURN mutation_not_found('User not found');
END IF;
-- Delete user (CASCADE will handle related records)
DELETE FROM users WHERE id = user_id::uuid;
-- Build cascade data for cache invalidation
cascade_data := jsonb_build_object(
'deleted', jsonb_build_array(
jsonb_build_object(
'__typename', 'User',
'id', deleted_user.id
)
),
'invalidations', jsonb_build_array(
'User:list', -- Invalidate user lists
'Post:author:' || deleted_user.id -- Invalidate user's posts
)
);
RETURN mutation_deleted('User deleted successfully')
WITH cascade = cascade_data;
END;
$$ LANGUAGE plpgsql;
Troubleshooting¶
Common Issues¶
Errors not appearing: Check status string format. Must be "failed:{reason}" or "not_found:{type}".
Wrong HTTP code: Status strings map to specific codes. See reference table above.
CASCADE not working: Verify enable_cascade=True in your mutation decorator.
Entity not showing: Check entity_type matches your GraphQL type name exactly.
Debug Queries¶
Check what your function returns:
-- Test your function
SELECT * FROM create_user('{"name": "Test", "email": "test@example.com"}');
-- Should return:
-- status: "created"
-- message: "User created successfully"
-- entity_id: "uuid-here"
-- entity_type: "User"
-- entity: {...}
Migration Guide¶
From ad-hoc field_errors¶
Before:
After:
@fraiseql.failure
class CreateUserError:
message: str
# errors array auto-populated from status strings
From MutationResultBase requirement¶
Before:
@fraiseql.success
class CreateUserSuccess(MutationResultBase):
user: User
@fraiseql.failure
class CreateUserError(MutationResultBase):
pass
After:
@fraiseql.success
class CreateUserSuccess:
user: User
message: str
@fraiseql.failure
class CreateUserError:
message: str
# errors auto-populated
From legacy format¶
Before:
-- Legacy format (deprecated)
RETURN jsonb_build_object(
'success', false,
'data', null,
'error', 'Validation failed'
);
After: