FraiseQL Extension Points: Customizing and Extending the Framework¶
Audience: Application developers, integration engineers, framework users
Executive Summary¶
FraiseQL v1 is a Python runtime GraphQL framework for PostgreSQL. You define your schema with decorators; at application startup the schema is built in memory and served over FastAPI. There is no compile step and no separate plugin runtime.
Extensibility is achieved through composition — you extend behavior by adding your own resolvers, scalars, middleware, and authorizers, and by pushing logic into PostgreSQL. The framework exposes the following real extension points:
| Extension point | What it customizes |
|---|---|
@fraiseql.field |
Computed / derived fields on a type (sync or async) |
@fraiseql.dataloader_field |
Batched field resolution to prevent N+1 queries |
| Custom scalars | Domain-specific typed values |
| FastAPI / ASGI middleware | Cross-cutting request/response behavior |
Authorizer + authorizer= |
Per-operation and global authorization |
| PostgreSQL | The deepest surface: fn_ functions, triggers, custom types, extensions |
1. Custom Field Resolvers¶
Use @fraiseql.field to add a computed or derived field to a @fraiseql.type. The
decorated method becomes a GraphQL field whose value is produced at resolution time
rather than read directly from the view's data JSONB.
import fraiseql
@fraiseql.field(resolver=None, description=None, track_n1=True)
def field(...): ...
The signature is:
resolver— an optional custom resolver to override the default behavior (defaults toNone, in which case the decorated method body is the resolver).description— the field description that appears in the GraphQL schema.track_n1— whether to track N+1 query patterns for this field (defaultTrue).
1.1 Synchronous computed field¶
import fraiseql
@fraiseql.type(sql_source="v_user")
class User:
id: fraiseql.ID
first_name: str
last_name: str
@fraiseql.field(description="User's full display name")
def display_name(self) -> str:
return f"{self.first_name} {self.last_name}"
1.2 Async field with database access¶
The resolver receives the GraphQL info object, so it can reach the request-scoped
repository through info.context["db"] and run additional reads.
import fraiseql
from uuid import UUID
@fraiseql.type(sql_source="v_user")
class User:
id: UUID
@fraiseql.field(description="Number of posts authored by this user")
async def post_count(self, info) -> int:
db = info.context["db"]
return await db.fetchval(
"SELECT count(*) FROM v_post WHERE author_id = $1", self.id
)
Because the parent object (self) and info are both available, a custom field can
combine already-loaded data with a targeted query. Keep these resolvers cheap: if a
field issues one query per parent row, batch it with a dataloader instead (next
section).
2. Dataloaders: Batching to Prevent N+1¶
When a field needs to look up a related record for every parent in a list, resolving
it one row at a time produces an N+1 query pattern. @fraiseql.dataloader_field
batches those lookups into a single load.
def dataloader_field(
loader_class: type[DataLoader],
*,
key_field: str,
description: str | None = None,
) -> ...: ...
loader_class— aDataLoadersubclass that knows how to batch-load by key.key_field— the attribute on the parent object holding the key to load.description— optional field description for the GraphQL schema.
The decorated method must have the signature (self, info) -> ReturnType; its body
is auto-implemented by the decorator.
import fraiseql
from uuid import UUID
from fraiseql.optimization.dataloader import DataLoader
class UserDataLoader(DataLoader):
async def batch_load(self, keys: list[UUID]) -> list["User | None"]:
db = self.context["db"]
rows = await db.find("v_user", id__in=keys)
by_id = {row.id: row for row in rows}
return [by_id.get(key) for key in keys]
@fraiseql.type(sql_source="v_post")
class Post:
id: UUID
author_id: UUID
@fraiseql.dataloader_field(UserDataLoader, key_field="author_id")
async def author(self, info) -> "User | None":
"""Load the post author. Batched across all posts in the request."""
... # implementation generated by the decorator
All author lookups across a page of posts collapse into one batched load instead
of one query per post.
See also: type system.
3. Custom Scalars¶
FraiseQL ships a library of domain scalars in fraiseql.types. Importing and
annotating a field with one of these gives you parsing, serialization, and GraphQL
schema typing for free.
import fraiseql
from fraiseql.types import ID, DateTime, EmailAddress, JSON, LTree
@fraiseql.type(sql_source="v_account")
class Account:
id: ID
email: EmailAddress # validated email scalar
created_at: DateTime # ISO-8601 timestamp scalar
metadata: JSON # arbitrary JSON payload
path: LTree # PostgreSQL ltree hierarchical path
Available scalars include ID, Date, DateTime, EmailAddress, JSON, LTree,
and many more domain types. Each scalar carries its own validation and
serialization, so an EmailAddress field rejects malformed input at the GraphQL
boundary before it ever reaches your resolver, and an LTree value maps cleanly onto
PostgreSQL's ltree type.
For the complete catalog and the rules each scalar enforces, see the scalars reference.
4. FastAPI / ASGI Middleware¶
FraiseQL serves its GraphQL endpoint as a FastAPI application, so the entire FastAPI and Starlette middleware ecosystem is available to you. There are two ways to add cross-cutting request/response behavior.
4.1 Add middleware to the generated app¶
create_fraiseql_app returns a FastAPI instance. Add standard middleware to it
with add_middleware:
import fraiseql
from fraiseql.fastapi import create_fraiseql_app
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[User],
queries=[users, user],
mutations=[create_user],
production=True,
)
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["POST"],
allow_headers=["authorization", "content-type"],
)
4.2 Mount FraiseQL inside a larger FastAPI app¶
To run FraiseQL alongside your own REST routes and middleware, build your FastAPI app
first and pass it to create_fraiseql_app via the app= parameter. FraiseQL extends
that app with its GraphQL endpoint rather than creating a new one:
from fastapi import FastAPI
from fraiseql.fastapi import create_fraiseql_app
parent = FastAPI(title="My Service")
@parent.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
# Add your own middleware to the parent app, then hand it to FraiseQL.
app = create_fraiseql_app(
app=parent,
database_url="postgresql://localhost/mydb",
types=[User],
queries=[users, user],
mutations=[create_user],
)
Because middleware runs at the ASGI layer, it wraps every GraphQL request uniformly — ideal for request IDs, structured logging, compression, CORS, and security headers.
5. Authorization¶
FraiseQL enforces authorization at the operation level through the Authorizer
protocol. An authorizer decides whether a top-level query, mutation, or subscription
may run for the current request.
5.1 The Authorizer protocol¶
from typing import Any
from fraiseql.security import AuthorizationDecision
class Authorizer:
async def authorize_operation(
self,
*,
context: dict[str, Any],
operation_type, # OperationType (query / mutation / subscription)
operation_name: str,
arguments: dict[str, Any],
) -> AuthorizationDecision | bool:
...
authorize_operation may be sync or async and may return either a plain bool (sugar
for allow/deny) or an AuthorizationDecision carrying a stable code, message, and
optional row filters. Enforcement is fail-closed: if no authorizer is configured
the operation is allowed; if a configured authorizer raises an unexpected error the
operation is denied and the raw error is never surfaced to the client.
5.2 Global and per-operation authorizers¶
Pass an authorizer to create_fraiseql_app to apply it to every operation, and
override it on a single operation with @fraiseql.query(authorizer=...) or
@fraiseql.subscription(authorizer=...). The per-operation authorizer wins over the
global default.
import fraiseql
from fraiseql.fastapi import create_fraiseql_app
class TenantAuthorizer:
async def authorize_operation(self, *, context, operation_type, operation_name, arguments):
return context.get("tenant_id") is not None
@fraiseql.query(authorizer=AdminOnlyAuthorizer())
async def all_tenants(info) -> list[Tenant]:
db = info.context["db"]
return await db.find("v_tenant")
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[Tenant],
queries=[all_tenants],
authorizer=TenantAuthorizer(), # global default
)
5.3 Decision caching¶
Repeated authorization checks for the same context and operation can be served from a
decision cache instead of re-running the authorizer. Enable it by passing an
AuthorizationCacheConfig to create_fraiseql_app:
from fraiseql.fastapi import create_fraiseql_app
from fraiseql.security import AuthorizationCacheConfig
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[Tenant],
queries=[all_tenants],
authorizer=TenantAuthorizer(),
authorization_cache=AuthorizationCacheConfig(),
)
A fresh cache hit replays the prior decision without calling the authorizer. Clean returns (allow or deny) are cached; an authorizer that raises hits the fail-closed branch and is never cached, so a transient error can neither pin a deny nor leak an allow.
5.4 Enterprise RBAC¶
For role-based access control at scale, the optional fraiseql.enterprise.rbac
module provides hierarchical roles, PostgreSQL-native permission caching with
automatic invalidation, and supporting GraphQL directives and middleware. Initialize
it during startup with setup_rbac_cache(db_pool) and layer it on top of the
Authorizer surface described above.
For the full authorization model, see the authorization guide.
6. PostgreSQL: The Deepest Extension Surface¶
In FraiseQL, PostgreSQL is not just storage — it is where most extension actually
happens. Reads are served from v_/tv_ views and writes run through fn_
functions, so you can extend behavior end to end without touching the framework.
6.1 Business logic in fn_ functions¶
Every mutation calls a PostgreSQL function that performs validation and the write, then returns JSONB describing success or failure. This is the primary place to put domain logic.
CREATE FUNCTION fn_create_user(input jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
new_id uuid;
BEGIN
IF NOT (input->>'email') ~ '^[^@]+@[^@]+$' THEN
RETURN jsonb_build_object('success', false, 'message', 'invalid email');
END IF;
INSERT INTO tb_user (id, name, email)
VALUES (gen_random_uuid(), input->>'name', input->>'email')
RETURNING id INTO new_id;
RETURN jsonb_build_object(
'success', true,
'user', jsonb_build_object('id', new_id, 'name', input->>'name')
);
END;
$$;
import fraiseql
@fraiseql.mutation
async def create_user(info, input: CreateUserInput) -> CreateUserSuccess | CreateUserError:
db = info.context["db"]
result = await db.execute_function(
"fn_create_user", {"name": input.name, "email": input.email}
)
if not result.get("success"):
return CreateUserError(message=result.get("message", "failed"))
return CreateUserSuccess(user=User(**result["user"]))
6.2 Triggers and custom types¶
Use triggers to keep projection views (tv_) in sync, maintain audit trails, or
populate denormalized columns. Use PostgreSQL CREATE TYPE (composite or enum types)
and DOMAIN constraints to model domain values at the database level — the shape your
views return flows straight into the GraphQL response.
6.3 PostgreSQL extensions¶
Because views are just SQL, any installed PostgreSQL extension is available to your read and write paths:
- pgvector — similarity search over embeddings inside a
v_view. - pg_trgm — fuzzy text matching and trigram indexes.
- PostGIS — geospatial queries and indexing.
- ltree — hierarchical paths, surfaced through the
LTreescalar.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE VIEW v_document_search AS
SELECT
d.id,
jsonb_build_object(
'id', d.id,
'title', d.title,
'similarity', 1 - (d.embedding <=> :query_embedding)
) AS data
FROM tb_document d
ORDER BY d.embedding <=> :query_embedding;
The resolver queries this view like any other read; the extension does the heavy lifting inside PostgreSQL.
7. Best Practices¶
DO:
- Keep custom field resolvers cheap; batch related lookups with a dataloader.
- Use
async/awaitfor any I/O in resolvers, authorizers, and middleware. - Push validation and write logic into
fn_functions where it stays transactional. - Use the built-in domain scalars before reaching for a custom type.
- Make authorizers fail-closed and free of side effects.
DON'T:
- Issue one query per row in a field resolver — that is the N+1 pattern dataloaders exist to prevent.
- Expose internal
pk_/fk_columns through a view'sdataJSONB. - Bypass the authorizer by reaching past
info.context. - Log sensitive data (tokens, passwords, full email lists) from middleware or hooks.