Skip to content

Custom Business Logic

FraiseQL does not have resolvers in the traditional GraphQL sense. There is no Python function that runs at request time, no info.context, no async def, no await db.execute(). The Python SDK is compile-time only.


In traditional GraphQL frameworks, you write a resolver function that runs for every request:

# This is NOT how FraiseQL works. Do not write this.
@fraiseql.query
async def user(id: str, info) -> User:
return await info.context["db"].execute("SELECT * FROM users WHERE id = $1", id)

In FraiseQL, the Python function body is never called. It is a schema declaration:

schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class User:
id: ID
name: str
email: str
# The function body is ignored at runtime.
# Only the signature matters: it tells the compiler the query name,
# its arguments, return type, and which SQL view to read from.
@fraiseql.query
def user(id: ID) -> User | None:
"""Look up a single user by UUID."""
return fraiseql.config(sql_source="v_user")
@fraiseql.query
def users(limit: int = 20, offset: int = 0) -> list[User]:
"""List users with pagination."""
return fraiseql.config(sql_source="v_user")

The Rust runtime reads sql_source="v_user" from the compiled schema and queries that view directly. No Python is involved at request time.


For mutations — any write that needs validation, business rules, or side effects — the “resolver” is a PostgreSQL function.

Mutation functions use the fn_ prefix with the form fn_{action}_{entity}:

fn_create_user fn_update_post fn_delete_comment fn_publish_post

Every mutation function returns a mutation_response composite:

CREATE TYPE mutation_response AS (
status TEXT, -- 'success', 'failed:validation', 'conflict:...'
message TEXT,
entity_id UUID,
entity_type TEXT,
entity JSONB,
updated_fields TEXT[],
cascade JSONB,
metadata JSONB
);
db/schema/03_functions/fn_publish_post.sql
CREATE OR REPLACE FUNCTION fn_publish_post(
p_post_id UUID,
p_user_id UUID -- injected from JWT sub claim by the Rust runtime
)
RETURNS mutation_response
LANGUAGE plpgsql
AS $$
DECLARE
v_pk_post BIGINT;
v_result mutation_response;
BEGIN
-- Verify ownership (using integer FK for performance)
SELECT pk_post INTO v_pk_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
v_result.status := 'failed:not_found';
v_result.message := 'Post not found or access denied';
RETURN v_result;
END IF;
-- Update
UPDATE tb_post
SET is_published = true,
published_at = now(),
updated_at = now()
WHERE pk_post = v_pk_post;
v_result.status := 'success';
v_result.entity_id := p_post_id;
RETURN v_result;
END;
$$;
schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class Post:
id: ID
title: str
is_published: bool
@fraiseql.mutation(
sql_source="fn_publish_post",
operation="UPDATE",
inject={"user_id": "jwt:sub"}, # Rust runtime injects JWT sub → p_user_id
)
def publish_post(post_id: ID) -> Post:
"""Publish a draft post. Ownership is verified in SQL."""
pass

The inject= parameter tells the compiler to forward the JWT sub claim as the user_id SQL parameter. The client cannot supply or forge this value.


“Computed fields” in FraiseQL are columns in your SQL view. There is no Python code to add computed fields — everything lives in the view definition.

Read views use the v_ prefix. Every view returns exactly two columns: id (UUID) and data (JSONB):

db/schema/02_read/v_user.sql
CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name,
'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 still active
'is_premium', u.subscription_expires_at > now()
) AS data
FROM tb_user u;
schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class User:
id: ID
identifier: str
name: str
email: str
display_name: str # computed in SQL
account_age_days: int # computed in SQL
is_premium: bool # computed in SQL

The JSONB key names are snake_case; FraiseQL converts them to camelCase in the GraphQL schema automatically.

Relationships are pre-joined in the view — no N+1 queries, no DataLoader:

db/schema/02_read/v_post.sql
CREATE VIEW v_post AS
SELECT
p.id,
jsonb_build_object(
'id', p.id::text,
'identifier', p.identifier,
'title', p.title,
'content', p.content,
'comment_count', (
SELECT COUNT(*) FROM tb_comment c WHERE c.fk_post = p.pk_post
),
'author', (
SELECT jsonb_build_object('id', u.id::text, 'name', u.name)
FROM tb_user u WHERE u.pk_user = p.fk_user
)
) AS data
FROM tb_post p;
schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class PostAuthor:
id: ID
name: str
@fraiseql.type
class Post:
id: ID
identifier: str
title: str
content: str
comment_count: int # aggregated in SQL
author: PostAuthor # pre-joined in SQL

PostgreSQL functions are transactional by default. If any step raises an exception, the entire function rolls back. There is no need to manage transactions in application code.

db/schema/03_functions/fn_transfer_credits.sql
CREATE OR REPLACE FUNCTION fn_transfer_credits(
p_from_user UUID,
p_to_user UUID,
p_amount INTEGER,
p_actor_id UUID -- injected from JWT sub claim
)
RETURNS mutation_response
LANGUAGE plpgsql
AS $$
DECLARE
v_result mutation_response;
BEGIN
IF p_amount <= 0 THEN
v_result.status := 'failed:validation';
v_result.message := 'Amount must be positive';
RETURN v_result;
END IF;
IF (SELECT credits FROM tb_user WHERE id = p_from_user) < p_amount THEN
v_result.status := 'failed:validation';
v_result.message := 'Insufficient credits';
RETURN v_result;
END IF;
UPDATE tb_user SET credits = credits - p_amount WHERE id = p_from_user;
UPDATE tb_user SET credits = credits + p_amount WHERE id = p_to_user;
INSERT INTO tb_credit_transfer (fk_from_user, fk_to_user, amount, created_at)
SELECT
(SELECT pk_user FROM tb_user WHERE id = p_from_user),
(SELECT pk_user FROM tb_user WHERE id = p_to_user),
p_amount,
now();
v_result.status := 'success';
RETURN v_result;
END;
$$;
schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.input
class TransferCreditsInput:
to_user_id: ID
amount: int
@fraiseql.type
class TransferResult:
from_user_id: ID
to_user_id: ID
amount: int
@fraiseql.mutation(
sql_source="fn_transfer_credits",
operation="CUSTOM",
inject={"actor_id": "jwt:sub"},
)
def transfer_credits(input: TransferCreditsInput) -> TransferResult:
"""Atomically transfer credits. Both accounts update or neither does."""
pass

Raise structured errors from PostgreSQL functions. FraiseQL maps RAISE EXCEPTION to GraphQL errors with structured extensions:

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:

{
"errors": [{
"message": "Post title already exists",
"extensions": {
"code": "unique_violation",
"detail": "A post with this slug already exists"
}
}]
}

For structured mutation results (success or failure without raising), use the mutation_response status field with patterns like 'failed:validation', 'conflict:duplicate_email', or 'success'.


Add cache_ttl_seconds= to @fraiseql.query to cache results in the Rust runtime:

schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.query
def posts(limit: int = 20, offset: int = 0) -> list[Post]:
"""List posts. Results are cached for 60 seconds."""
return fraiseql.config(sql_source="v_post", cache_ttl_seconds=60) # cache results for 60 seconds

The caching backend is configured in fraiseql.toml:

fraiseql.toml
[caching]
enabled = true
backend = "redis" # or "memory"

Custom SQL functions work as resolvers for all transports. A custom function exposed via @fraiseql.query is available via GraphQL, REST (if annotated with rest_path), and gRPC (when [grpc] is enabled).

If a use case genuinely cannot be expressed in PostgreSQL, the correct approaches are:

  1. A separate service — FraiseQL serves the data API; a dedicated service handles the complex logic. Connect them via a mutation that calls out to the service (write the result to the database first, then let FraiseQL serve it).
  2. A pre-processing step — Transform or enrich data before it enters the database. Once it is in PostgreSQL, FraiseQL serves it without any Python code at request time.
  3. PostgreSQL extensionspg_jsonschema, pgvector, pg_cron, pg_net, and others extend what is possible in SQL without leaving the database layer.