Custom Queries
Custom Business Logic
FraiseQL replaces resolver functions with SQL views — there is no resolver layer to write or maintain. Custom business logic lives in three places:
| Where | What it’s for |
|---|---|
| PostgreSQL functions | Mutations — transactional writes, validation, side effects |
| Middleware | Cross-cutting concerns — auth context, audit logging, rate limiting |
| SQL views | Computed fields — derived values, aggregations, filtered relationships |
Business Logic in Mutations
Section titled “Business Logic in Mutations”Mutations are the primary place for custom business logic. Each @fraiseql.mutation maps to a PostgreSQL function that runs transactionally.
Simple Mutation
Section titled “Simple Mutation”CREATE OR REPLACE FUNCTION fn_publish_post( p_post_id UUID, p_user_id UUID)RETURNS SETOF v_postLANGUAGE plpgsqlAS $$DECLARE v_post tb_post;BEGIN -- Verify ownership SELECT * INTO v_post FROM tb_post WHERE id = p_post_id AND fk_user = ( SELECT pk_user FROM tb_user WHERE id = p_user_id );
IF NOT FOUND THEN RAISE EXCEPTION 'Post not found or access denied'; END IF;
IF v_post.is_published THEN RAISE EXCEPTION 'Post is already published'; END IF;
-- Publish UPDATE tb_post SET is_published = true, updated_at = now() WHERE pk_post = v_post.pk_post;
RETURN QUERY SELECT * FROM v_post WHERE id = p_post_id;END;$$;import fraiseqlfrom fraiseql.scalars import IDfrom fraiseql.auth import authenticated, requires_scope
@fraiseql.mutation@authenticated@requires_scope("write:posts")def publish_post(info, id: ID) -> "Post": """Publish a draft post.
Calls fn_publish_post(p_post_id, p_user_id) which verifies ownership and updates is_published = true. """ passimport { fraiseqlMutation, authenticated, requiresScope } from 'fraiseql';import { ID } from 'fraiseql/scalars';
@fraiseqlMutation()@authenticated@requiresScope('write:posts')function publishPost(id: ID): Post { /** * Publish a draft post. * Calls fn_publish_post which verifies ownership. */}Transactional Mutation
Section titled “Transactional Mutation”PostgreSQL functions run in a transaction by default — if any step fails, everything rolls back automatically.
CREATE OR REPLACE FUNCTION fn_transfer_credits( p_from_user UUID, p_to_user UUID, p_amount INTEGER)RETURNS SETOF v_transfer_resultLANGUAGE plpgsqlAS $$BEGIN -- Validate IF p_amount <= 0 THEN RAISE EXCEPTION 'Amount must be positive'; END IF;
-- Check balance IF (SELECT credits FROM tb_user WHERE id = p_from_user) < p_amount THEN RAISE EXCEPTION 'Insufficient credits'; END IF;
-- Debit UPDATE tb_user SET credits = credits - p_amount WHERE id = p_from_user;
-- Credit UPDATE tb_user SET credits = credits + p_amount WHERE id = p_to_user;
-- Audit INSERT INTO tb_credit_transfer (from_user, to_user, amount, created_at) VALUES (p_from_user, p_to_user, p_amount, now());
RETURN QUERY SELECT * FROM v_transfer_result WHERE from_user = p_from_user ORDER BY created_at DESC LIMIT 1;END;$$;import fraiseqlfrom fraiseql.scalars import IDfrom fraiseql.auth import authenticated
@fraiseql.inputclass TransferCreditsInput: to_user_id: ID amount: int
@fraiseql.mutation@authenticateddef transfer_credits(info, input: TransferCreditsInput) -> "TransferResult": """Transfer credits between accounts.
Calls fn_transfer_credits — atomically debit/credit both accounts. Rolls back automatically if either update fails. """ passCalling External Services in Mutations
Section titled “Calling External Services in Mutations”When your mutation needs to call an external service (email, webhooks, etc.), the pattern is:
- Write to the database first (inside the PostgreSQL function)
- Trigger side effects after the transaction commits (in Python)
import fraiseqlimport asyncio
@fraiseql.mutation@authenticatedasync def create_user(info, input: "CreateUserInput") -> "User": """Create a user account and send a welcome email.
The PostgreSQL function fn_create_user handles the database write. Python handles the post-commit side effect (email). """ user = await info.context["db"].call_function("fn_create_user", input)
# Fire-and-forget: don't block the response on email delivery asyncio.create_task(send_welcome_email(user.email, user.username))
return userCross-Cutting Logic in Middleware
Section titled “Cross-Cutting Logic in Middleware”Use @fraiseql.middleware for logic that applies across many queries and mutations: extracting context, audit logging, rate limiting.
Context Extraction
Section titled “Context Extraction”import fraiseql
@fraiseql.middlewaredef extract_context(request, next): """Make auth claims available to all queries and mutations.""" if request.auth: request.context["current_user_id"] = request.auth.claims.get("sub") request.context["user_scopes"] = request.auth.claims.get("scopes", []) request.context["org_id"] = request.auth.claims.get("org_id") return next(request)Context values are available in:
row_filterexpressions:row_filter="org_id = {org_id}"- Mutation
info.contextdict:info.context["current_user_id"]
Audit Logging
Section titled “Audit Logging”import fraiseqlfrom datetime import datetime
@fraiseql.middlewareasync def audit_mutations(request, next): """Record all mutations to the audit log.""" result = await next(request)
if request.operation_type == "mutation": await request.context["db"].execute( """INSERT INTO tb_audit_log (user_id, operation, ip_address, occurred_at) VALUES ($1, $2, $3, $4)""", request.context.get("current_user_id"), request.operation_name, request.client_ip, datetime.utcnow(), )
return resultRequest Tracing
Section titled “Request Tracing”import fraiseqlimport uuid
@fraiseql.middlewaredef add_request_id(request, next): """Attach a unique request ID for distributed tracing.""" request.context["request_id"] = ( request.headers.get("X-Request-ID") or str(uuid.uuid4()) ) return next(request)Middleware Order
Section titled “Middleware Order”Middleware runs in registration order. Define context-setting middleware before middleware that uses the context:
# 1. First: extract context from JWT@fraiseql.middlewaredef extract_context(request, next): ...
# 2. Second: use context for tenant isolation@fraiseql.middlewareasync def validate_tenant(request, next): ...
# 3. Third: audit (needs user_id from step 1)@fraiseql.middlewareasync def audit_mutations(request, next): ...Computed Fields in SQL Views
Section titled “Computed Fields in SQL Views”In FraiseQL, “computed fields” are columns in your SQL view — not Python code. This keeps computation close to the data and avoids extra round-trips.
Derived Scalar
Section titled “Derived Scalar”CREATE VIEW v_user ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'username', u.username, 'email', u.email, -- Computed: full display name 'display_name', u.first_name || ' ' || u.last_name, -- Computed: account age in days 'account_age_days', EXTRACT(DAY FROM now() - u.created_at)::int, -- Computed: subscription status 'is_premium', u.subscription_expires_at > now() ) AS dataFROM tb_user u;@fraiseql.typeclass User: id: ID username: str display_name: str # Computed in SQL account_age_days: int # Computed in SQL is_premium: bool # Computed in SQLAggregated Relationship
Section titled “Aggregated Relationship”CREATE VIEW v_post ASSELECT p.id, jsonb_build_object( 'id', p.id::text, 'title', p.title, 'comment_count', ( SELECT COUNT(*) FROM tb_comment c WHERE c.fk_post = p.pk_post ), 'top_comments', ( SELECT jsonb_agg(vc.data ORDER BY vc.created_at DESC) FROM v_comment vc WHERE vc.fk_post = p.pk_post LIMIT 3 ) ) AS dataFROM tb_post p;@fraiseql.typeclass Post: id: ID title: str comment_count: int # Aggregated in SQL top_comments: list[Comment] # Pre-joined in SQLError Handling
Section titled “Error Handling”Raise errors from PostgreSQL functions using RAISE EXCEPTION. FraiseQL maps these to GraphQL errors with structured extensions:
-- Raise with error code (optional but useful for clients)RAISE EXCEPTION 'Post title already exists' USING ERRCODE = 'unique_violation', DETAIL = 'A post with this slug already exists', HINT = 'Choose a different title';The GraphQL response will be:
{ "errors": [{ "message": "Post title already exists", "extensions": { "code": "unique_violation", "detail": "A post with this slug already exists" } }]}For rate limiting and other cross-cutting policies, use fraiseql.toml — FraiseQL enforces these in the Rust runtime before the query reaches the database:
[security.rate_limiting]enabled = truerequests_per_minute = 1000per_user = trueWhen SQL Views Aren’t Enough
Section titled “When SQL Views Aren’t Enough”If your use case genuinely requires logic that cannot be expressed in SQL or PostgreSQL functions, consider:
- A separate service — FraiseQL handles your data API; a service handles complex business logic; connect them via a mutation that calls the service
- A pre-processing step — Transform data before it enters the database, then let FraiseQL serve it
- PostgreSQL extensions —
pg_jsonschema,pgvector,pg_cron, etc. extend what you can do in SQL
Next Steps
Section titled “Next Steps”Observers
Error Handling
Testing