Authentication
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.
The Compile-Time-Only Rule
Section titled “The Compile-Time-Only Rule”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.queryasync 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:
import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass 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.querydef user(id: ID) -> User | None: """Look up a single user by UUID.""" return fraiseql.config(sql_source="v_user")
@fraiseql.querydef 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.
SQL Functions as Resolvers
Section titled “SQL Functions as Resolvers”For mutations — any write that needs validation, business rules, or side effects — the “resolver” is a PostgreSQL function.
Naming Convention
Section titled “Naming Convention”Mutation functions use the fn_ prefix with the form fn_{action}_{entity}:
fn_create_user fn_update_post fn_delete_comment fn_publish_postMutation Function Shape
Section titled “Mutation Function Shape”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);Example: Publishing a Post
Section titled “Example: Publishing a Post”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_responseLANGUAGE plpgsqlAS $$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;$$;import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass 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.""" passThe 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 SQL Views
Section titled “Computed Fields in SQL Views”“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.
View Naming Convention
Section titled “View Naming Convention”Read views use the v_ prefix. Every view returns exactly two columns: id (UUID) and data (JSONB):
CREATE VIEW v_user ASSELECT 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 dataFROM tb_user u;import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass 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 SQLThe JSONB key names are snake_case; FraiseQL converts them to camelCase in the GraphQL schema automatically.
Pre-joined Relationships
Section titled “Pre-joined Relationships”Relationships are pre-joined in the view — no N+1 queries, no DataLoader:
CREATE VIEW v_post ASSELECT 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 dataFROM tb_post p;import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass PostAuthor: id: ID name: str
@fraiseql.typeclass Post: id: ID identifier: str title: str content: str comment_count: int # aggregated in SQL author: PostAuthor # pre-joined in SQLTransactional Mutations
Section titled “Transactional Mutations”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.
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_responseLANGUAGE plpgsqlAS $$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;$$;import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.inputclass TransferCreditsInput: to_user_id: ID amount: int
@fraiseql.typeclass 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.""" passError Handling
Section titled “Error Handling”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'.
Caching Queries
Section titled “Caching Queries”Add cache_ttl_seconds= to @fraiseql.query to cache results in the Rust runtime:
import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.querydef 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 secondsThe caching backend is configured in fraiseql.toml:
[caching]enabled = truebackend = "redis" # or "memory"Transport Compatibility
Section titled “Transport Compatibility”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).
When SQL Is Not Enough
Section titled “When SQL Is Not Enough”If a use case genuinely cannot be expressed in PostgreSQL, the correct approaches are:
- 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).
- 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.
- PostgreSQL extensions —
pg_jsonschema,pgvector,pg_cron,pg_net, and others extend what is possible in SQL without leaving the database layer.
Next Steps
Section titled “Next Steps”Observers
Error Handling
Naming Conventions