Developer-Owned SQL
Why you write the SQL views and how FraiseQL maps them.
FraiseQL lets you define your GraphQL schema in your preferred programming language. Each type maps to a SQL view you’ve written (e.g., User maps to v_user). This page covers the Python syntax; other languages follow similar patterns.
import fraiseql
@fraiseql.typeclass User: """A user in the system.""" id: str name: str email: str created_at: strThe docstring becomes the GraphQL type description.
| Python Type | GraphQL Type |
|---|---|
str | String! |
int | Int! |
float | Float! |
bool | Boolean! |
str | None | String |
list[str] | [String!]! |
list[str] | None | [String!] |
@fraiseql.typeclass User: id: str name: str bio: str | None # Optional field@fraiseql.typeclass User: id: str tags: list[str] # Required list nicknames: list[str] | None # Optional listFraiseQL’s convention is to give every entity three distinct identifiers. This is called the Trinity Pattern.
pk_user BIGINT -- internal join key, never leaves the databaseid UUID -- public API identifier, safe to exposeidentifier TEXT -- human-readable (email, username), can changeMost systems conflate internal keys, public identifiers, and human-readable handles. When they’re the same column, you face trade-offs:
| If you use… | Problem when… |
|---|---|
| Auto-increment int as public ID | Users can enumerate records by incrementing |
| UUID as join key | Foreign keys are 36 bytes; index bloat at scale |
| Email as primary key | User changes email → cascading FK updates |
The Trinity Pattern uses each identifier for what it’s good at:
pk_user BIGINT — fast joins, compact indexes, never leaves the databaseid UUID — public API identifier, safe to expose, non-enumerableidentifier TEXT — human input (email, username), can change without touching foreign keysFraiseQL enforces this at compile time when relay=True is set: the view must include pk_{entity} in its JSONB output. The pk is used for cursor generation and stripped before the response leaves the server.
@fraiseql.typeclass User: id: str name: str posts: list['Post'] # User has many posts
@fraiseql.typeclass Post: id: str title: str author: User # Post belongs to user@fraiseql.typeclass Post: id: str title: str tags: list['Tag']
@fraiseql.typeclass Tag: id: str name: str posts: list['Post']You create the join table (tb_post_tag) in your database schema. FraiseQL maps the relationship through your SQL view, which handles the join.
@fraiseql.typeclass Category: id: str name: str parent: 'Category | None' children: list['Category']For mutations, define input types:
@fraiseql.inputclass CreateUserInput: name: str email: str bio: str | None = None # Optional with default
@fraiseql.inputclass UpdateUserInput: name: str | None = None email: str | None = None bio: str | None = Noneimport enum
class UserRole(enum.Enum): ADMIN = "admin" USER = "user" GUEST = "guest"
@fraiseql.typeclass User: id: str name: str role: UserRolefrom datetime import datetimefrom decimal import Decimal@fraiseql.typeclass Order: id: ID total: Decimal created_at: datetimeBuilt-in scalar mappings:
| Python Type | GraphQL Scalar |
|---|---|
datetime | DateTime |
date | Date |
time | Time |
Decimal | Decimal |
UUID | UUID |
FraiseQL creates default query endpoints for each type, mapped to the corresponding SQL view:
users — List query (SELECT data FROM v_user)user(id: ID!) — Single record query (SELECT data FROM v_user WHERE id = $1)posts — List query (SELECT data FROM v_post)post(id: ID!) — Single record query (SELECT data FROM v_post WHERE id = $1)Map a query to any SQL view you own using sql_source:
@fraiseql.querydef active_users(limit: int = 10) -> list[User]: """Get all users — FraiseQL adds WHERE filters from arguments automatically.""" return fraiseql.config(sql_source="v_user")
@fraiseql.querydef posts_by_author(author_id: fraiseql.ID) -> list[Post]: """Get posts for a specific author.""" return fraiseql.config(sql_source="v_post")FraiseQL compiles the sql_source (set via fraiseql.config() in the function body) into optimized SQL at build time. At runtime it executes SELECT data FROM v_user WHERE ... and maps the result to the return type.
Transport annotations are optional parameters that extend operations to additional transports. Omit them to serve an operation via GraphQL only. Add rest_path/rest_method to expose via REST. gRPC endpoints are auto-generated when [grpc] is enabled — see gRPC Transport.
@fraiseql.query( rest_path="/posts", # REST: GET /rest/v1/posts (default path; configure via [rest] path) rest_method="GET",)def posts(limit: int = 10) -> list[Post]: return fraiseql.config(sql_source="v_post")The same annotations are available in TypeScript:
@Query({ sqlSource: 'v_post', restPath: '/posts', restMethod: 'GET',})async function posts(limit: number = 10): Promise<Post[]> {}Transport annotations are additive — the GraphQL endpoint always remains available regardless of which other transports are configured.
FraiseQL creates default mutation endpoints mapped to SQL functions you write (fn_create_user, fn_update_user, fn_delete_user).
Map a mutation to a SQL function using sql_source and operation:
@fraiseql.mutation(sql_source="fn_publish_post", operation="UPDATE")def publish_post(id: fraiseql.ID) -> Post: """Publish a draft post.""" pass
@fraiseql.mutation(sql_source="fn_archive_user", operation="UPDATE")def archive_user(id: fraiseql.ID) -> bool: """Archive a user and their content.""" passThe corresponding SQL functions implement the business logic and return a mutation_response:
CREATE FUNCTION fn_publish_post(p_id UUID) RETURNS mutation_response AS $$DECLARE v_id UUID;BEGIN UPDATE tb_post SET is_published = true, published_at = NOW() WHERE id = p_id RETURNING id INTO v_id;
IF NOT FOUND THEN RETURN ROW('failed:not_found', 'Post not found', NULL, 'Post', NULL, NULL, NULL, NULL)::mutation_response; END IF;
PERFORM sync_tv_post_single(v_id); RETURN ROW('success', 'Post published', v_id, 'Post', (SELECT data FROM v_post WHERE id = v_id), NULL, NULL, NULL)::mutation_response;END;$$ LANGUAGE plpgsql;Use fraiseql.field(requires_scope=...) to restrict access to sensitive fields:
from typing import Annotatedimport fraiseql
@fraiseql.typeclass User: id: str name: str # Requires scope to access — returns FORBIDDEN if missing email: Annotated[str, fraiseql.field(requires_scope="read:User.email")] # Mask mode: returns null for unauthorized users instead of failing phone: Annotated[str | None, fraiseql.field(requires_scope="read:User.phone", on_deny="mask")]To exclude a field entirely (e.g., password_hash), simply omit it from your Python type. Since FraiseQL reads from SQL views, any column not in the view’s JSONB output is never exposed — no decorator needed.
| Approach | Effect | When to use |
|---|---|---|
requires_scope="..." | Field in schema, rejected or masked without scope | PII, internal fields |
on_deny="mask" | Field returns null when scope missing | Optional exposure |
| Omit from type + view | Field never in GraphQL schema | Secrets, hashes |
Computed fields are calculated in the SQL view. Declare them as regular fields in the Python type:
@fraiseql.typeclass User: id: str first_name: str last_name: str full_name: str # Computed in v_user via first_name || ' ' || last_nameThere is no @fraiseql.computed decorator. The computation happens in the SQL view’s jsonb_build_object().
@fraiseql.typeclass User: id: str name: str username: str = fraiseql.field(deprecated="Use 'name' instead")import { Type, input, query, enumType } from 'fraiseql';
// Enum@enumType()enum UserRole { ADMIN = 'admin', USER = 'user', GUEST = 'guest',}
// Object types with relationships@Type()class User { id: string; name: string; email: string; role: UserRole; posts: Post[]; // one-to-many bio?: string; // optional field}
@Type()class Post { id: string; title: string; content: string; author: User; // many-to-one tags: Tag[]; // many-to-many}
@Type()class Tag { id: string; name: string; posts: Post[];}
// Input type@Input()class CreateUserInput { name: string; email: string; role?: UserRole;}package schema
// Enum — use a typed string constant grouptype UserRole string
const ( UserRoleAdmin UserRole = "admin" UserRoleUser UserRole = "user" UserRoleGuest UserRole = "guest")
// Object types with relationshipstype User struct { ID string `fraiseql:"id"` Name string `fraiseql:"name"` Email string `fraiseql:"email"` Role UserRole `fraiseql:"role"` Posts []Post `fraiseql:"posts"` // one-to-many Bio *string `fraiseql:"bio"` // optional (pointer = nullable)}
type Post struct { ID string `fraiseql:"id"` Title string `fraiseql:"title"` Content string `fraiseql:"content"` Author User `fraiseql:"author"` // many-to-one Tags []Tag `fraiseql:"tags"` // many-to-many}
type Tag struct { ID string `fraiseql:"id"` Name string `fraiseql:"name"` Posts []Post `fraiseql:"posts"`}Define a simple type in your schema:
import fraiseql
@fraiseql.typeclass Product: """A product in the catalog.""" id: str name: str price: float in_stock: boolCompile the schema:
fraiseql compileYou should see output like:
Compiling schema...✓ Parsed 1 types✓ Generated GraphQL schema✓ Wrote schema.compiled.jsonStart FraiseQL and introspect:
fraiseql run &
# Introspect the schemacurl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __type(name: \"Product\") { fields { name type { name } } } }"}'Expected response:
{ "data": { "__type": { "fields": [ {"name": "id", "type": {"name": "String"}}, {"name": "name", "type": {"name": "String"}}, {"name": "price", "type": {"name": "Float"}}, {"name": "inStock", "type": {"name": "Boolean"}} ] } }}Test a relationship:
@fraiseql.typeclass Category: id: str name: str products: list['Product']
@fraiseql.typeclass Product: id: str name: str category: CategoryCompile again and verify:
fraiseql compilefraiseql runTest queries are generated:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __schema { queryType { fields { name } } } }"}' | jq '.data.__schema.queryType.fields[].name'Should include:
"product""products""category""categories"Organize your schema into logical modules:
schema/├── __init__.py├── users.py # User, Profile, Authentication├── products.py # Product, Category, Inventory├── orders.py # Order, LineItem, Payment└── common.py # Shared types, scalarsAlways include docstrings for complex types:
@fraiseql.typeclass Order: """ Represents a customer order.
The order lifecycle: 1. Created (pending) 2. Payment processed 3. Shipped 4. Delivered (or Cancelled)
Use the `status` field to track order state. """ id: str status: OrderStatus # pending, processing, shipped, delivered, cancelled total: Decimal line_items: list['LineItem']Follow GraphQL conventions (camelCase in GraphQL, snake_case in Python):
@fraiseql.typeclass User: # Python: snake_case created_at: DateTime # Maps to createdAt in GraphQL first_name: str # Maps to firstName in GraphQL is_active: bool # Maps to isActive in GraphQLValidation is implemented in your PostgreSQL fn_* functions via RAISE EXCEPTION, or at the type level using custom scalars. There is no fraiseql.validate() function:
from typing import Annotatedimport fraiseql
@fraiseql.inputclass CreateUserInput: email: str # Validate in fn_create_user() with RAISE EXCEPTION age: int # Validate in fn_create_user() with RAISE EXCEPTION
# For reusable scalar validation, use a CustomScalar:# email: Email # Where Email is a fraiseql.scalar subclassing CustomScalar“Type not found”:
Error: Type 'Product' referenced but not defined'Product'“Field has no type annotation”:
Error: Field 'name' in User has no type annotationstr, int, float, bool for basic types“Cannot resolve field”:
Error: Cannot resolve field 'category' on type 'Product'v_product) has the field“Type mismatch”:
Error: Expected String, got Int for field 'price'Decimal for monetary values, not floatDeveloper-Owned SQL
Why you write the SQL views and how FraiseQL maps them.
CQRS Pattern
The table/view separation at the heart of FraiseQL.
TOML Configuration
Configure your project with a simple TOML file.
Queries and Mutations
Full API reference for GraphQL queries and mutations.