Your First API
Build a complete blog API with users, posts, and comments. This guide shows the full workflow: write tables, write views, build the database with 🍯 Confiture, compile the GraphQL mapping, and serve.
What We’re Building
Section titled “What We’re Building”A GraphQL API with:
- Users with profiles
- Blog posts with authors
- Comments on posts
- Queries and mutations
Prerequisites
Section titled “Prerequisites”This guide requires a running database.
FraiseQL supports PostgreSQL, MySQL, SQLite, and SQL Server.
For this tutorial, we recommend PostgreSQL for the best JSON support and the smoothest experience.
Option A — Docker (Fastest Setup)
docker run --name blog-postgres \ -e POSTGRES_USER=user \ -e POSTGRES_PASSWORD=password \ -e POSTGRES_DB=db_blog \ -p 5432:5432 \ -d postgres:16Verify connection:
psql postgresql://user:password@localhost:5432/db_blogEnable UUID support:
CREATE EXTENSION IF NOT EXISTS pgcrypto;Connection String
[database]type = "postgresql"url = "postgresql://user:password@localhost:5432/db_blog"Documentation:
PostgreSQL Docker image
pgcrypto extension
Create Database
CREATE DATABASE db_blog;Connection String
[database]type = "mysql"url = "mysql://user:password@localhost:3306/db_blog"Requires MySQL 8.0+ (expression defaults for UUID).
Documentation:
MySQL Downloads
Default expressions documentation
No server installation required.
Connection String
[database]type = "sqlite"url = "sqlite://./db_blog.db"UUIDs must be generated in the application layer.
SQLite does not enforce foreign keys by default. You must enable them per connection:
PRAGMA foreign_keys = ON;Documentation:
SQLite Downloads
Foreign Key Support
Create Database
CREATE DATABASE db_blog;Connection String
[database]type = "sqlserver"url = "sqlserver://user:password@localhost:1433/db_blog"Requires SQL Server 2019+ (JSON support).
Documentation:
SQL Server Downloads
JSON Support in SQL Server
Once your database is running and configured, continue to the next step.
Step 1: Initialize the Project
Section titled “Step 1: Initialize the Project”mkdir blog-api && cd blog-apifraiseql init .This creates the project structure including the db/schema/ directory where your SQL lives.
Step 2: Write the Database Schema
Section titled “Step 2: Write the Database Schema”Create your tables in db/schema/01_write/. The schema uses the Trinity pattern: a surrogate key (pk_*), a UUID/external identifier (id), and a text identifier. This pattern works across all databases:
CREATE TABLE tb_user ( /*keys*/ pk_user BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), identifier TEXT UNIQUE NOT NULL,
/*business fields*/ username VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, bio TEXT,
/*audit fields*/ created_at TIMESTAMPTZ DEFAULT NOW());CREATE TABLE tb_post ( /*keys*/ pk_post BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), identifier TEXT UNIQUE NOT NULL, fk_user BIGINT NOT NULL REFERENCES tb_user(pk_user),
/*business fields*/ title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, content TEXT NOT NULL, is_published BOOLEAN DEFAULT false,
/*audit fields*/ created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW());
/*indexes*/CREATE INDEX ix_tb_post_fk_user ON tb_post(fk_user);CREATE TABLE tb_comment ( /*keys*/ pk_comment BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), identifier TEXT UNIQUE NOT NULL, fk_user BIGINT NOT NULL REFERENCES tb_user(pk_user), fk_post BIGINT NOT NULL REFERENCES tb_post(pk_post),
/*business fields*/ content TEXT NOT NULL,
/*audit fields*/ created_at TIMESTAMPTZ DEFAULT NOW());
/*indexes*/CREATE INDEX ix_tb_comment_fk_user ON tb_comment(fk_user);CREATE INDEX ix_tb_comment_fk_post ON tb_comment(fk_post);CREATE TABLE tb_user ( /*keys*/ pk_user BIGINT PRIMARY KEY AUTO_INCREMENT, id CHAR(36) NOT NULL UNIQUE DEFAULT (UUID()), identifier VARCHAR(255) UNIQUE NOT NULL,
/*business fields*/ username VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, bio TEXT,
/*audit fields*/ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);CREATE TABLE tb_post ( /*keys*/ pk_post BIGINT PRIMARY KEY AUTO_INCREMENT, id CHAR(36) NOT NULL UNIQUE DEFAULT (UUID()), identifier VARCHAR(255) UNIQUE NOT NULL, fk_user BIGINT NOT NULL,
/*business fields*/ title TEXT NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL, content LONGTEXT NOT NULL, is_published BOOLEAN DEFAULT false,
/*audit fields*/ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
/*constraints*/ CONSTRAINT FK_tb_post_user FOREIGN KEY (fk_user) REFERENCES tb_user(pk_user) ON DELETE RESTRICT);CREATE TABLE tb_comment ( /*keys*/ pk_comment BIGINT PRIMARY KEY AUTO_INCREMENT, id CHAR(36) NOT NULL UNIQUE DEFAULT (UUID()), identifier VARCHAR(255) UNIQUE NOT NULL, fk_user BIGINT NOT NULL, fk_post BIGINT NOT NULL,
/*business fields*/ content TEXT NOT NULL,
/*audit fields*/ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
/*constraints*/ CONSTRAINT FK_tb_comment_user FOREIGN KEY (fk_user) REFERENCES tb_user(pk_user) ON DELETE RESTRICT, CONSTRAINT FK_tb_comment_post FOREIGN KEY (fk_post) REFERENCES tb_post(pk_post) ON DELETE RESTRICT);CREATE TABLE tb_user ( /*keys*/ pk_user INTEGER PRIMARY KEY, id TEXT NOT NULL, identifier TEXT NOT NULL,
/*business fields*/ username TEXT NOT NULL, email TEXT NOT NULL, bio TEXT,
/*audit fields*/ created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
/*constraints*/ CONSTRAINT uq_tb_user_id UNIQUE (id), CONSTRAINT uq_tb_user_identifier UNIQUE (identifier), CONSTRAINT uq_tb_user_username UNIQUE (username), CONSTRAINT uq_tb_user_email UNIQUE (email));CREATE TABLE tb_post ( /*keys*/ pk_post INTEGER PRIMARY KEY, id TEXT NOT NULL, identifier TEXT NOT NULL, fk_user INTEGER NOT NULL,
/*business fields*/ title TEXT NOT NULL, slug TEXT NOT NULL, content TEXT NOT NULL, is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0,1)),
/*audit fields*/ created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), updated_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
/*constraints*/ CONSTRAINT uq_tb_post_id UNIQUE (id), CONSTRAINT uq_tb_post_identifier UNIQUE (identifier), CONSTRAINT uq_tb_post_slug UNIQUE (slug));
/*indexes*/CREATE INDEX ix_tb_post_fk_user ON tb_post(fk_user);CREATE TABLE tb_comment ( /*keys*/ pk_comment INTEGER PRIMARY KEY, id TEXT NOT NULL, identifier TEXT NOT NULL, fk_user INTEGER NOT NULL, fk_post INTEGER NOT NULL,
/*business fields*/ content TEXT NOT NULL,
/*audit fields*/ created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
/*constraints*/ CONSTRAINT uq_tb_comment_id UNIQUE (id), CONSTRAINT uq_tb_comment_identifier UNIQUE (identifier), CONSTRAINT fk_tb_comment_user FOREIGN KEY (fk_user) REFERENCES tb_user(pk_user) ON DELETE RESTRICT, CONSTRAINT fk_tb_comment_post FOREIGN KEY (fk_post) REFERENCES tb_post(pk_post) ON DELETE RESTRICT);
/*indexes*/CREATE INDEX ix_tb_comment_fk_user ON tb_comment(fk_user);CREATE INDEX ix_tb_comment_fk_post ON tb_comment(fk_post);CREATE TABLE dbo.tb_user ( /*keys*/ pk_user BIGINT IDENTITY(1,1) NOT NULL, id UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_tb_user_id DEFAULT NEWSEQUENTIALID(), identifier NVARCHAR(255) NOT NULL,
/*business fields*/ username NVARCHAR(255) NOT NULL, email NVARCHAR(255) NOT NULL, bio NVARCHAR(MAX) NULL,
/*audit fields*/ created_at DATETIMEOFFSET(3) NOT NULL CONSTRAINT DF_tb_user_created_at DEFAULT SYSDATETIMEOFFSET(),
/*constraints*/ CONSTRAINT PK_tb_user PRIMARY KEY CLUSTERED (pk_user), CONSTRAINT UQ_tb_user_id UNIQUE (id), CONSTRAINT UQ_tb_user_identifier UNIQUE (identifier), CONSTRAINT UQ_tb_user_username UNIQUE (username), CONSTRAINT UQ_tb_user_email UNIQUE (email));CREATE TABLE dbo.tb_post ( /*keys*/ pk_post BIGINT IDENTITY(1,1) NOT NULL, id UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_tb_post_id DEFAULT NEWSEQUENTIALID(), identifier NVARCHAR(255) NOT NULL, fk_user BIGINT NOT NULL,
/*business fields*/ title NVARCHAR(MAX) NOT NULL, slug NVARCHAR(255) NOT NULL, content NVARCHAR(MAX) NOT NULL, is_published BIT NOT NULL CONSTRAINT DF_tb_post_is_published DEFAULT 0,
/*audit fields*/ created_at DATETIMEOFFSET(3) NOT NULL CONSTRAINT DF_tb_post_created_at DEFAULT SYSDATETIMEOFFSET(), updated_at DATETIMEOFFSET(3) NOT NULL CONSTRAINT DF_tb_post_updated_at DEFAULT SYSDATETIMEOFFSET(),
/*constraints*/ CONSTRAINT PK_tb_post PRIMARY KEY CLUSTERED (pk_post), CONSTRAINT UQ_tb_post_id UNIQUE (id), CONSTRAINT UQ_tb_post_identifier UNIQUE (identifier), CONSTRAINT UQ_tb_post_slug UNIQUE (slug), CONSTRAINT FK_tb_post_user FOREIGN KEY (fk_user) REFERENCES dbo.tb_user(pk_user) ON DELETE NO ACTION);
/*indexes*/CREATE INDEX IX_tb_post_fk_user ON dbo.tb_post(fk_user);CREATE TABLE dbo.tb_comment ( /*keys*/ pk_comment BIGINT IDENTITY(1,1) NOT NULL, id UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_tb_comment_id DEFAULT NEWSEQUENTIALID(), identifier NVARCHAR(255) NOT NULL, fk_user BIGINT NOT NULL, fk_post BIGINT NOT NULL,
/*business fields*/ content NVARCHAR(MAX) NOT NULL,
/*audit fields*/ created_at DATETIMEOFFSET(3) NOT NULL CONSTRAINT DF_tb_comment_created_at DEFAULT SYSDATETIMEOFFSET(),
/*constraints*/ CONSTRAINT PK_tb_comment PRIMARY KEY CLUSTERED (pk_comment), CONSTRAINT UQ_tb_comment_id UNIQUE (id), CONSTRAINT UQ_tb_comment_identifier UNIQUE (identifier), CONSTRAINT FK_tb_comment_user FOREIGN KEY (fk_user) REFERENCES dbo.tb_user(pk_user) ON DELETE NO ACTION, CONSTRAINT FK_tb_comment_post FOREIGN KEY (fk_post) REFERENCES dbo.tb_post(pk_post) ON DELETE NO ACTION);
/*indexes*/CREATE INDEX IX_tb_comment_fk_user ON dbo.tb_comment(fk_user);CREATE INDEX IX_tb_comment_fk_post ON dbo.tb_comment(fk_post);Note on updated_at: Only MySQL auto-updates this column via ON UPDATE CURRENT_TIMESTAMP. On PostgreSQL, SQLite, and SQL Server, your database functions (called via mutations) must set updated_at explicitly — the column records what your function passes, not a database-managed timestamp.
Step 3: Write the SQL Views
Section titled “Step 3: Write the SQL Views”Create views in db/schema/02_read/ following the .data JSONB pattern:
CREATE VIEW v_user ASSELECT u.id, jsonb_build_object( /*keys*/ 'id', u.id::text,
/*scalar fields*/ 'username', u.username, 'email', u.email, 'bio', u.bio, 'created_at', u.created_at ) AS dataFROM tb_user u;CREATE VIEW v_comment ASSELECT c.id, c.fk_post, jsonb_build_object( /*keys*/ 'id', c.id::text,
/*scalar fields*/ 'content', c.content, 'created_at', c.created_at,
/*relationships*/ 'author', vu.data ) AS dataFROM tb_comment cJOIN tb_user u ON u.pk_user = c.fk_userJOIN v_user vu ON vu.id = u.id;CREATE VIEW v_post ASSELECT p.id, jsonb_build_object( /*keys*/ 'id', p.id::text,
/*scalar fields*/ 'title', p.title, 'slug', p.slug, 'content', p.content, 'is_published', p.is_published,
/*relationships*/ 'author', vu.data, 'comments', COALESCE( jsonb_agg(vc.data) FILTER (WHERE vc.id IS NOT NULL), '[]'::jsonb ) ) AS dataFROM tb_post pJOIN tb_user u ON u.pk_user = p.fk_userJOIN v_user vu ON vu.id = u.idLEFT JOIN v_comment vc ON vc.fk_post = p.pk_postGROUP BY p.pk_post, vu.data;CREATE VIEW v_user ASSELECT u.id, JSON_OBJECT( /*keys*/ 'id', u.id,
/*scalar fields*/ 'username', u.username, 'email', u.email, 'bio', u.bio, 'created_at', u.created_at ) AS dataFROM tb_user u;CREATE VIEW v_comment ASSELECT c.id, c.fk_post, JSON_OBJECT( /*keys*/ 'id', c.id,
/*scalar fields*/ 'content', c.content, 'created_at', c.created_at,
/*relationships*/ 'author', vu.data ) AS dataFROM tb_comment cJOIN tb_user u ON u.pk_user = c.fk_userJOIN v_user vu ON vu.id = u.id;CREATE VIEW v_post ASSELECT p.id, JSON_OBJECT( /*keys*/ 'id', p.id,
/*scalar fields*/ 'title', p.title, 'slug', p.slug, 'content', p.content, 'is_published', p.is_published,
/*relationships*/ 'author', vu.data, 'comments', COALESCE( JSON_ARRAYAGG(vc.data), JSON_ARRAY() ) ) AS dataFROM tb_post pJOIN tb_user u ON u.pk_user = p.fk_userJOIN v_user vu ON vu.id = u.idLEFT JOIN v_comment vc ON vc.fk_post = p.pk_postGROUP BY p.pk_post, vu.data;CREATE VIEW v_user ASSELECT u.id, json_object( /*keys*/ 'id', u.id,
/*scalar fields*/ 'username', u.username, 'email', u.email, 'bio', u.bio, 'created_at', u.created_at ) AS dataFROM tb_user u;CREATE VIEW v_comment ASSELECT c.id, c.fk_post, json_object( /*keys*/ 'id', c.id,
/*scalar fields*/ 'content', c.content, 'created_at', c.created_at,
/*relationships*/ 'author', vu.data ) AS dataFROM tb_comment cJOIN tb_user u ON u.pk_user = c.fk_userJOIN v_user vu ON vu.id = u.id;CREATE VIEW v_post ASSELECT p.id, json_object( /*keys*/ 'id', p.id,
/*scalar fields*/ 'title', p.title, 'slug', p.slug, 'content', p.content, 'is_published', p.is_published,
/*relationships*/ 'author', vu.data, 'comments', COALESCE( (SELECT json_group_array(vc.data) FROM v_comment vc WHERE vc.fk_post = p.pk_post), json_array() ) ) AS dataFROM tb_post pJOIN tb_user u ON u.pk_user = p.fk_userJOIN v_user vu ON vu.id = u.id;CREATE VIEW dbo.v_user ASSELECT u.id, ( SELECT /*keys*/ u.id,
/*scalar fields*/ u.username, u.email, u.bio, u.created_at FOR JSON PATH, WITHOUT_ARRAY_WRAPPER ) AS dataFROM dbo.tb_user u;CREATE VIEW dbo.v_comment ASSELECT c.id, c.fk_post, ( SELECT /*keys*/ c.id,
/*scalar fields*/ c.content, c.created_at,
/*relationships*/ JSON_QUERY(vu.data) AS author FOR JSON PATH, WITHOUT_ARRAY_WRAPPER ) AS dataFROM dbo.tb_comment cJOIN dbo.tb_user u ON u.pk_user = c.fk_userJOIN dbo.v_user vu ON vu.id = u.id;CREATE VIEW dbo.v_post ASSELECT p.id, ( SELECT /*keys*/ p.id,
/*scalar fields*/ p.title, p.slug, p.content, p.is_published,
/*relationships*/ JSON_QUERY(vu.data) AS author, JSON_QUERY(COALESCE( (SELECT '[' + STRING_AGG(vc.data, ',') + ']' FROM dbo.v_comment vc WHERE vc.fk_post = p.pk_post), '[]' )) AS comments FOR JSON PATH, WITHOUT_ARRAY_WRAPPER ) AS dataFROM dbo.tb_post pJOIN dbo.tb_user u ON u.pk_user = p.fk_userJOIN dbo.v_user vu ON vu.id = u.id;Key insight: Views compose other views. v_post uses vu.data and jsonb_agg(vc.data) — no duplication of field definitions. Add a field to v_user once, and it appears everywhere v_user.data is embedded.
Step 4: Define the GraphQL Schema
Section titled “Step 4: Define the GraphQL Schema”Replace schema.py with:
import fraiseqlfrom fraiseql.scalars import ID, Email, Slug, DateTime
@fraiseql.typeclass User: """A blog user.""" id: ID username: str email: Email bio: str | None created_at: DateTime posts: list['Post']
@fraiseql.typeclass Post: """A blog post.""" id: ID title: str slug: Slug content: str published: bool created_at: DateTime updated_at: DateTime author: User comments: list['Comment']
@fraiseql.typeclass Comment: """A comment on a post.""" id: ID content: str created_at: DateTime author: User post: Post
# Input types for mutations@fraiseql.inputclass CreateUserInput: username: str email: Email bio: str | None = None
@fraiseql.inputclass CreatePostInput: title: str content: str author_id: ID published: bool = False
@fraiseql.inputclass CreateCommentInput: content: str post_id: ID author_id: IDReplace schema.ts with:
import { type, input } from 'fraiseql';import { ID, Email, Slug, DateTime } from 'fraiseql/scalars';
@type()class User { /** A blog user. */ id: ID; username: string; email: Email; bio: string | null; createdAt: DateTime; posts: Post[];}
@type()class Post { /** A blog post. */ id: ID; title: string; slug: Slug; content: string; published: boolean; createdAt: DateTime; updatedAt: DateTime; author: User; comments: Comment[];}
@type()class Comment { /** A comment on a post. */ id: ID; content: string; createdAt: DateTime; author: User; post: Post;}
// Input types for mutations@input()class CreateUserInput { username: string; email: Email; bio?: string;}
@input()class CreatePostInput { title: string; content: string; authorId: ID; published: boolean = false;}
@input()class CreateCommentInput { content: string; postId: ID; authorId: ID;}Replace schema.go with:
package schema
import "github.com/fraiseql/fraiseql-go/scalars"
// User represents a blog user.type User struct { ID scalars.ID `fraiseql:"type"` Username string Email scalars.Email Bio *string CreatedAt scalars.DateTime Posts []Post}
// Post represents a blog post.type Post struct { ID scalars.ID `fraiseql:"type"` Title string Slug scalars.Slug Content string Published bool CreatedAt scalars.DateTime UpdatedAt scalars.DateTime Author User Comments []Comment}
// Comment represents a comment on a post.type Comment struct { ID scalars.ID `fraiseql:"type"` Content string CreatedAt scalars.DateTime Author User Post Post}
// Input types for mutations
// CreateUserInput is the input for creating a user.type CreateUserInput struct { Username string `fraiseql:"input"` Email scalars.Email Bio *string}
// CreatePostInput is the input for creating a post.type CreatePostInput struct { Title string `fraiseql:"input"` Content string AuthorID scalars.ID Published bool}
// CreateCommentInput is the input for creating a comment.type CreateCommentInput struct { Content string `fraiseql:"input"` PostID scalars.ID AuthorID scalars.ID}Step 5: Configure the Project
Section titled “Step 5: Configure the Project”[project]name = "blog-api"version = "1.0.0"
[database]url = "postgresql://user:password@localhost:5432/db_blog"
[server]port = 8080host = "127.0.0.1"
[server.cors]origins = ["http://localhost:3000"][project]name = "blog-api"version = "1.0.0"
[database]url = "mysql://user:password@localhost:3306/db_blog"
[server]port = 8080host = "127.0.0.1"
[server.cors]origins = ["http://localhost:3000"][project]name = "blog-api"version = "1.0.0"
[database]url = "sqlite://./db_blog.db"
[server]port = 8080host = "127.0.0.1"
[server.cors]origins = ["http://localhost:3000"][project]name = "blog-api"version = "1.0.0"
[database]url = "sqlserver://user:password@localhost:1433/db_blog"
[server]port = 8080host = "127.0.0.1"
[server.cors]origins = ["http://localhost:3000"]Step 6: Build the Database
Section titled “Step 6: Build the Database”Use 🍯 Confiture to create the database from your DDL files:
# Build fresh database from all SQL filesconfiture build --env local✓ Schema built successfully in 0.4s → Created 3 tables, 3 viewsThis concatenates and executes all your SQL files in order: tables first, then views.
Step 7: Compile and Serve
Section titled “Step 7: Compile and Serve”# Compile the GraphQL mappingfraiseql compile
# Start the serverfraiseql runYour API is now running at http://localhost:8080/graphql.
Step 8: Test the API
Section titled “Step 8: Test the API”Open the GraphQL Playground at http://localhost:8080/graphql and try these queries:
Create a User
Section titled “Create a User”mutation { createUser(input: { username: "alice" email: "alice@example.com" bio: "Software developer" }) { id username }}Response:
{ "data": { "createUser": { "id": "usr_01HZ3K", "username": "alice" } }}Create a Post
Section titled “Create a Post”mutation { createPost(input: { title: "Hello World" content: "This is my first post!" authorId: "usr_01HZ3K" published: true }) { id title slug }}Response:
{ "data": { "createPost": { "id": "pst_01HZ4M", "title": "Hello World", "slug": "hello-world" } }}Query Posts with Authors
Section titled “Query Posts with Authors”query { posts { id title content author { username } comments { content author { username } } }}Response:
{ "data": { "posts": [ { "id": "pst_01HZ4M", "title": "Hello World", "content": "This is my first post!", "author": { "username": "alice" }, "comments": [] } ] }}Query a Single User
Section titled “Query a Single User”query { user(id: "usr_01HZ3K") { username email posts { title published } }}Response:
{ "data": { "user": { "username": "alice", "email": "alice@example.com", "posts": [ { "title": "Hello World", "published": true } ] } }}What Happened Behind the Scenes
Section titled “What Happened Behind the Scenes”- You wrote the SQL — Tables (
tb_*) for writes, views (v_*) for reads - 🍯 Confiture built the database — DDL files concatenated and executed in under 1s
- FraiseQL compiled the mapping — GraphQL types mapped to SQL views
- Queries resolve to SQL —
posts { author { ... } }becomesSELECT data FROM v_post
The nested GraphQL response comes from JSON composition in your views. v_post embeds v_user.data for the author and aggregates v_comment.data for comments. One query per GraphQL request, no application-level N+1.
Adding Custom Logic
Section titled “Adding Custom Logic”Sometimes you need a query that cannot be expressed automatically. The v_user view exposes scalar fields only — it does not embed posts. v_post embeds the author (v_user.data), but the reverse — a user with all their posts — requires explicit SQL composition. This is a classic reverse-relationship that a custom view handles cleanly.
Define an AuthorProfile type and map it to a dedicated view:
@fraiseql.typeclass AuthorProfile: id: ID username: str email: Email bio: str | None created_at: DateTime posts: list[Post]
@fraiseql.query(sql_source="v_author_profile")def author_profile(id: ID) -> AuthorProfile | None: """Get an author with all their posts.""" passimport { type, query } from 'fraiseql';import { ID, Email, DateTime } from 'fraiseql/scalars';
@type()class AuthorProfile { id: ID; username: string; email: Email; bio: string | null; createdAt: DateTime; posts: Post[];}
@query({ sqlSource: 'v_author_profile' })async function authorProfile(id: ID): Promise<AuthorProfile | null> { // Get an author with all their posts}// AuthorProfile represents a user with all their posts.type AuthorProfile struct { ID scalars.ID `fraiseql:"type"` Username string Email scalars.Email Bio *string CreatedAt scalars.DateTime Posts []Post}
// AuthorProfile returns an author with all their posts.// Maps to v_author_profile view.func AuthorProfile(ctx context.Context, id scalars.ID) (*AuthorProfile, error) { return nil, nil}Then write the view that shapes the data at the SQL level:
CREATE VIEW v_author_profile ASSELECT u.id, jsonb_build_object( /*keys*/ 'id', u.id::text,
/*scalar fields*/ 'username', u.username, 'email', u.email, 'bio', u.bio, 'created_at', u.created_at,
/*relationships*/ 'posts', COALESCE( jsonb_agg(vp.data) FILTER (WHERE vp.id IS NOT NULL), '[]'::jsonb ) ) AS dataFROM tb_user uLEFT JOIN tb_post tp ON tp.fk_user = u.pk_userLEFT JOIN v_post vp ON vp.id = tp.idGROUP BY u.pk_user;CREATE VIEW v_author_profile ASSELECT u.id, JSON_OBJECT( /*keys*/ 'id', u.id,
/*scalar fields*/ 'username', u.username, 'email', u.email, 'bio', u.bio, 'created_at', u.created_at,
/*relationships*/ 'posts', COALESCE(JSON_ARRAYAGG(vp.data), JSON_ARRAY()) ) AS dataFROM tb_user uLEFT JOIN tb_post tp ON tp.fk_user = u.pk_userLEFT JOIN v_post vp ON vp.id = tp.idGROUP BY u.pk_user;CREATE VIEW v_author_profile ASSELECT u.id, json_object( /*keys*/ 'id', u.id,
/*scalar fields*/ 'username', u.username, 'email', u.email, 'bio', u.bio, 'created_at', u.created_at,
/*relationships*/ 'posts', COALESCE( (SELECT json_group_array(vp.data) FROM tb_post tp JOIN v_post vp ON vp.id = tp.id WHERE tp.fk_user = u.pk_user), json_array() ) ) AS dataFROM tb_user u;CREATE VIEW dbo.v_author_profile ASSELECT u.id, ( SELECT /*keys*/ u.id,
/*scalar fields*/ u.username, u.email, u.bio, u.created_at,
/*relationships*/ JSON_QUERY(COALESCE( (SELECT '[' + STRING_AGG(vp.data, ',') + ']' FROM dbo.tb_post tp JOIN dbo.v_post vp ON vp.id = tp.id WHERE tp.fk_user = u.pk_user), '[]' )) AS posts FOR JSON PATH, WITHOUT_ARRAY_WRAPPER ) AS dataFROM dbo.tb_user u;This is the power of the database-first approach: shape data for the UI entirely at the SQL level. v_author_profile composes v_post the same way v_post composes v_user — no custom resolvers, no N+1, one query.
For business logic and side effects, use observers to trigger actions automatically.
Next Steps
Section titled “Next Steps”- Developer-Owned SQL — Why SQL ownership is a strength
- 🍯 Confiture — Database lifecycle management
- Schema Definition — Advanced type patterns
- Authentication — Add user auth
- Deployment — Deploy to production