FraiseQL v1 - Advanced Patterns (DEFAULT)¶
Core patterns for FraiseQL v1: Production-grade database architecture
Pattern 1: Trinity Identifiers (DEFAULT)¶
The Problem¶
Single-ID systems have trade-offs:
| ID Type | Pros | Cons |
|---|---|---|
| Serial/Autoincrement | Fast joins, sequential | Not globally unique, exposes growth rate |
| UUID | Globally unique, secure | Slower joins, random order |
| Slug/Username | Human-friendly, SEO | Can't use as PK (changes), not all entities have one |
Solution: Use all three! Each for its purpose.
Trinity Pattern - Revised Naming¶
-- ============================================
-- COMMAND SIDE (tb_*)
-- ============================================
CREATE TABLE tb_organisation (
-- Primary Key: SERIAL for fast internal joins
pk_organisation SERIAL PRIMARY KEY,
-- Public ID: UUID for GraphQL API (secure, doesn't expose count)
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
-- Human identifier: TEXT for user-facing URLs
identifier TEXT UNIQUE NOT NULL, -- e.g., "acme-corp"
-- Regular fields
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tb_user (
-- Primary Key: SERIAL (internal, fast)
pk_user SERIAL PRIMARY KEY,
-- Foreign Key: INT referencing pk_organisation (fast FK!)
fk_organisation INT NOT NULL REFERENCES tb_organisation(pk_organisation),
-- Public ID: UUID for GraphQL API
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
-- Human identifier: username/slug
identifier TEXT UNIQUE NOT NULL, -- e.g., "john-doe"
-- Regular fields
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tb_post (
-- Primary Key: SERIAL
pk_post SERIAL PRIMARY KEY,
-- Foreign Key: INT referencing pk_user (fast!)
fk_user INT NOT NULL REFERENCES tb_user(pk_user),
-- Public ID: UUID
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
-- Human identifier: slug
identifier TEXT UNIQUE NOT NULL, -- e.g., "my-first-post"
-- Regular fields
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for lookups
CREATE INDEX idx_tb_user_id ON tb_user(id); -- UUID lookups
CREATE INDEX idx_tb_user_identifier ON tb_user(identifier); -- Slug lookups
CREATE INDEX idx_tb_user_fk_organisation ON tb_user(fk_organisation); -- FK joins
-- ============================================
-- QUERY SIDE (tv_*)
-- ============================================
-- Clean! Only UUID and identifier exposed
CREATE TABLE tv_user (
id UUID PRIMARY KEY, -- Just UUID! (clean GraphQL API)
identifier TEXT UNIQUE NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tv_post (
id UUID PRIMARY KEY,
identifier TEXT UNIQUE NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Naming Convention (FINAL):
- pk_* = SERIAL PRIMARY KEY (internal, fast joins)
- fk_* = INT FOREIGN KEY (references another table's pk_*)
- id = UUID (public API identifier, exposed in GraphQL)
- identifier = TEXT (human-readable: username, slug, etc.)
Benefits¶
| Use Case | ID to Use | Why |
|---|---|---|
| GraphQL ID field | id (UUID) |
Secure, globally unique, doesn't leak info |
| Database joins | pk_*, fk_* (SERIAL) |
Fast INT joins (10x faster than UUID) |
| User-facing URLs | identifier (slug) |
SEO-friendly, memorable |
| API lookup | id or identifier |
Flexible, user chooses |
Example GraphQL queries:
# By public UUID (secure)
query {
user(id: "550e8400-e29b-41d4-a716-446655440000") {
id
identifier
name
}
}
# By human identifier (friendly)
query {
user(identifier: "john-doe") {
id
identifier
name
}
}
# URL-friendly: /users/john-doe
Sync Functions¶
-- Sync tv_user from tb_user (receives UUID)
CREATE OR REPLACE FUNCTION fn_sync_tv_user(p_id UUID)
RETURNS void AS $$
BEGIN
INSERT INTO tv_user (id, identifier, data, updated_at)
SELECT
u.id, -- UUID
u.identifier,
jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name,
'email', u.email,
'organisation', (
SELECT jsonb_build_object(
'id', o.id::text,
'identifier', o.identifier,
'name', o.name
)
FROM tb_organisation o
WHERE o.pk_organisation = u.fk_organisation -- Fast INT join!
),
'createdAt', u.created_at
),
NOW()
FROM tb_user u
WHERE u.id = p_id -- Find by UUID
ON CONFLICT (id) DO UPDATE
SET data = EXCLUDED.data, updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- Sync tv_post from tb_post
CREATE OR REPLACE FUNCTION fn_sync_tv_post(p_id UUID)
RETURNS void AS $$
BEGIN
INSERT INTO tv_post (id, identifier, data, updated_at)
SELECT
p.id,
p.identifier,
jsonb_build_object(
'id', p.id::text,
'identifier', p.identifier,
'title', p.title,
'content', p.content,
'createdAt', p.created_at,
'author', (
SELECT jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name
)
FROM tb_user u
WHERE u.pk_user = p.fk_user -- Fast INT join!
)
),
NOW()
FROM tb_post p
WHERE p.id = p_id
ON CONFLICT (id) DO UPDATE
SET data = EXCLUDED.data, updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
Python API (Clean!)¶
import fraiseql
from fraiseql.types import ID
@fraiseql.type
class Organisation:
id: ID # ✅ Clean! Just "id" (UUID)
identifier: str # "acme-corp"
name: str
@fraiseql.type
class User:
id: ID # ✅ Clean! Just "id" (UUID)
identifier: str # "john-doe"
name: str
email: str
organisation: Organisation
@fraiseql.type
class Post:
id: ID # ✅ Clean! Just "id" (UUID)
identifier: str # "my-first-post"
title: str
content: str
author: User
# Query by UUID or identifier
@fraiseql.query
async def user(
info,
id: ID | None = None,
identifier: str | None = None
) -> User | None:
"""Get user by UUID or identifier"""
repo = QueryRepository(info.context["db"])
if id:
return await repo.find_one("tv_user", id=id)
elif identifier:
return await repo.find_by_identifier("tv_user", identifier)
else:
raise ValueError("Must provide id or identifier")
# Mutations return UUID
@fraiseql.mutation
async def create_user(
info,
organisation: str, # Organisation identifier (human-friendly!)
identifier: str, # User identifier (username)
name: str,
email: str
) -> User:
"""Create user with human-friendly identifiers"""
db = info.context["db"]
# ✅ Just call the function - that's it!
try:
id = await db.fetchval(
"SELECT fn_create_user($1, $2, $3, $4)",
organisation, identifier, name, email
)
except Exception as e:
# Database raises meaningful errors
raise GraphQLError(str(e))
# Read from query side
repo = QueryRepository(db)
return await repo.find_one("tv_user", id=id)
Configuration¶
from fraiseql import FraiseQLConfig
config = FraiseQLConfig(
# Trinity identifier pattern (DEFAULT in v1)
trinity_identifiers=True,
# Naming conventions
primary_key_prefix="pk_", # pk_user, pk_post
foreign_key_prefix="fk_", # fk_organisation, fk_user
public_id_column="id", # UUID column
identifier_column="identifier" # Human-readable column
)
Why This Naming is Better¶
1. Intuitive Database Schema
-- Crystal clear what each field does:
pk_user -- "This is the primary key"
fk_organisation -- "This is a foreign key to organisation"
id -- "This is the public UUID identifier"
identifier -- "This is the human-readable slug/username"
2. Clean GraphQL Schema
type User {
id: ID! # ✅ Standard GraphQL convention (just "id")
identifier: String!
name: String!
}
# NOT:
type User {
pkUser: UUID! # ❌ Ugly, exposes internals
internalId: Int! # ❌ Confusing
}
3. Fast Database Joins
-- Joins use fast SERIAL integers
SELECT u.name, o.name, p.title
FROM tb_user u
JOIN tb_organisation o ON u.fk_organisation = o.pk_organisation -- Fast INT!
JOIN tb_post p ON p.fk_user = u.pk_user -- Fast INT!
WHERE u.id = '550e8400-...' -- Lookup by UUID
Performance: INT joins are ~10x faster than UUID joins
When to Use Trinity Pattern¶
✅ Use when (RECOMMENDED): - Building public APIs (UUIDs are safer) - Need fast internal joins (serial IDs) - Want user-friendly URLs (slugs/usernames) - Multi-tenant systems - High-scale systems (millions+ rows)
❌ Skip when: - Internal tools only - Simple CRUD apps (< 10 tables) - Single-tenant systems - Low scale (< 100K rows)
Pattern 2: Mutations as Database Functions (DEFAULT)¶
The Problem¶
Traditional approach (Python-heavy):
@fraiseql.mutation
async def create_user(info, name: str, email: str) -> User:
db = info.context["db"]
# ❌ Business logic in Python (not reusable)
if not email_is_valid(email):
raise ValueError("Invalid email")
# ❌ Manual transaction management
async with db.transaction():
id = await db.fetchval(
"INSERT INTO tb_user (name, email) VALUES ($1, $2) RETURNING id",
name, email
)
# ❌ Manual sync (can forget!)
await sync_tv_user(db, id)
repo = QueryRepository(db)
return await repo.find_one("tv_user", id=id)
Problems: - Business logic in Python (not reusable from psql, cron, etc.) - Manual transaction management (easy to mess up) - Manual sync calls (can forget) - Hard to test in isolation (need Python app) - Can't call from other contexts
Better: Database Functions (DEFAULT)¶
All business logic in PostgreSQL:
CREATE OR REPLACE FUNCTION fn_create_user(
p_organisation_identifier TEXT,
p_identifier TEXT,
p_name TEXT,
p_email TEXT
)
RETURNS UUID AS $$
DECLARE
v_fk_organisation INT;
v_id UUID;
BEGIN
-- Resolve organisation by identifier (human-friendly!)
SELECT pk_organisation INTO v_fk_organisation
FROM tb_organisation
WHERE identifier = p_organisation_identifier;
IF NOT FOUND THEN
RAISE EXCEPTION 'Organisation not found: %', p_organisation_identifier;
END IF;
-- Validation (in database)
IF p_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$' THEN
RAISE EXCEPTION 'Invalid email format';
END IF;
IF EXISTS (SELECT 1 FROM tb_user WHERE identifier = p_identifier) THEN
RAISE EXCEPTION 'Identifier already taken';
END IF;
-- Insert (transaction is automatic)
INSERT INTO tb_user (fk_organisation, identifier, name, email)
VALUES (v_fk_organisation, p_identifier, p_name, p_email)
RETURNING id INTO v_id;
-- Sync to query side (explicit, same transaction)
PERFORM fn_sync_tv_user(v_id);
-- Return public UUID
RETURN v_id;
EXCEPTION
WHEN unique_violation THEN
RAISE EXCEPTION 'User identifier or email already exists';
WHEN others THEN
RAISE;
END;
$$ LANGUAGE plpgsql;
Python becomes trivial:
@fraiseql.mutation
async def create_user(
info,
organisation: str, # Organisation identifier
identifier: str, # Username
name: str,
email: str
) -> User:
"""Create user (business logic in database)"""
db = info.context["db"]
# ✅ Just call the function - that's it!
try:
id = await db.fetchval(
"SELECT fn_create_user($1, $2, $3, $4)",
organisation, identifier, name, email
)
except Exception as e:
# Database raises meaningful errors
raise GraphQLError(str(e))
# Read from query side
repo = QueryRepository(db)
return await repo.find_one("tv_user", id=id)
Benefits¶
| Aspect | Python Logic | Database Function | Winner |
|---|---|---|---|
| Transaction | Manual async with |
Automatic | DB |
| Validation | Python code | SQL + constraints | DB |
| Reusability | Python only | psql, cron, triggers | DB |
| Testing | Need Python app | Direct SQL tests | DB |
| Sync | Manual await | Explicit in function | DB |
| Atomic | Hope you got it right | Guaranteed | DB |
| Versioning | Python migrations | SQL migrations | DB |
| Performance | Multiple round-trips | Single call | DB |
Database functions win on every metric.
Pattern Structure¶
Naming Convention:
fn_create_* Create entity (INSERT + sync) → returns UUID
fn_update_* Update entity (UPDATE + sync) → returns UUID
fn_delete_* Delete entity (DELETE + cascade) → returns BOOLEAN
fn_sync_tv_* Sync command → query side
fn_* Custom business logic
Example: Complete CRUD:
-- CREATE
CREATE FUNCTION fn_create_post(
p_user_identifier TEXT, -- Look up user by identifier!
p_identifier TEXT,
p_title TEXT,
p_content TEXT
) RETURNS UUID AS $$
DECLARE
v_fk_user INT;
v_id UUID;
BEGIN
-- Resolve user by identifier (human-friendly API!)
SELECT pk_user INTO v_fk_user
FROM tb_user
WHERE identifier = p_user_identifier;
IF NOT FOUND THEN
RAISE EXCEPTION 'User not found: %', p_user_identifier;
END IF;
INSERT INTO tb_post (fk_user, identifier, title, content)
VALUES (v_fk_user, p_identifier, p_title, p_content)
RETURNING id INTO v_id;
PERFORM fn_sync_tv_post(v_id);
RETURN v_id;
END;
$$ LANGUAGE plpgsql;
-- UPDATE
CREATE FUNCTION fn_update_post(
p_id UUID,
p_title TEXT,
p_content TEXT
) RETURNS UUID AS $$
BEGIN
UPDATE tb_post
SET title = p_title, content = p_content, updated_at = NOW()
WHERE id = p_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Post not found';
END IF;
PERFORM fn_sync_tv_post(p_id);
RETURN p_id;
END;
$$ LANGUAGE plpgsql;
-- DELETE
CREATE FUNCTION fn_delete_post(p_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
-- Delete from query side first
DELETE FROM tv_post WHERE id = p_id;
-- Then from command side
DELETE FROM tb_post WHERE id = p_id;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
Python mutations (all follow same trivial pattern):
from fraiseql.types import ID
@fraiseql.mutation
async def create_post(
info,
author: str, # Author identifier (username)
identifier: str, # Post slug
title: str,
content: str
) -> Post:
db = info.context["db"]
id = await db.fetchval(
"SELECT fn_create_post($1, $2, $3, $4)",
author, identifier, title, content
)
return await QueryRepository(db).find_one("tv_post", id=id)
@fraiseql.mutation
async def update_post(info, id: ID, title: str, content: str) -> Post:
db = info.context["db"]
id = await db.fetchval("SELECT fn_update_post($1, $2, $3)", id, title, content)
return await QueryRepository(db).find_one("tv_post", id=id)
@fraiseql.mutation
async def delete_post(info, id: ID) -> bool:
db = info.context["db"]
return await db.fetchval("SELECT fn_delete_post($1)", id)
Pattern: Python is thin wrapper. Database has all logic.
Testing Database Functions¶
-- tests/test_mutations.sql (using pgTAP)
BEGIN;
SELECT plan(5);
-- Test: Create user with valid data
SELECT lives_ok(
$$SELECT fn_create_user('acme-corp', 'john-doe', 'John Doe', 'john@example.com')$$,
'Create user succeeds'
);
SELECT is(
(SELECT name FROM tb_user WHERE identifier = 'john-doe'),
'John Doe',
'User inserted correctly'
);
SELECT is(
(SELECT data->>'name' FROM tv_user WHERE identifier = 'john-doe'),
'John Doe',
'Query side synced correctly'
);
-- Test: Duplicate identifier fails
SELECT throws_ok(
$$SELECT fn_create_user('acme-corp', 'john-doe', 'Jane Doe', 'jane@example.com')$$,
'Identifier already taken',
'Duplicate identifier rejected'
);
-- Test: Invalid email fails
SELECT throws_ok(
$$SELECT fn_create_user('acme-corp', 'jane-doe', 'Jane Doe', 'not-an-email')$$,
'Invalid email format',
'Invalid email rejected'
);
SELECT finish();
ROLLBACK;
Test directly in PostgreSQL - no Python needed!
Run with: psql -f tests/test_mutations.sql
Configuration¶
from fraiseql import FraiseQLConfig
config = FraiseQLConfig(
# Use database functions for all mutations (DEFAULT)
mutations_as_functions=True,
# Function naming convention
mutation_function_prefix="fn_",
sync_function_prefix="fn_sync_tv_",
# Auto-generate missing functions? (v1.1 feature)
auto_generate_functions=False,
)
CLI Codegen Support¶
# Analyze existing functions
fraiseql analyze --functions
# Output:
# ✓ Found 6 mutation functions
# - fn_create_user(org, identifier, name, email) → UUID
# - fn_update_user(id, name) → UUID
# - fn_delete_user(id) → BOOLEAN
# - fn_create_post(user, identifier, title, content) → UUID
# - fn_update_post(id, title, content) → UUID
# - fn_delete_post(id) → BOOLEAN
#
# ✓ All mutation functions follow naming convention
# ✓ All functions include sync calls
# Generate missing functions for new table
fraiseql codegen functions --table tb_comment
# Output: migrations/004_comment_functions.sql
Generated function (following pattern):
-- Generated by fraiseql codegen
CREATE FUNCTION fn_create_comment(
p_post_identifier TEXT,
p_user_identifier TEXT,
p_content TEXT
) RETURNS UUID AS $$
DECLARE
v_fk_post INT;
v_fk_user INT;
v_id UUID;
BEGIN
-- Resolve foreign keys by identifier
SELECT pk_post INTO v_fk_post FROM tb_post WHERE identifier = p_post_identifier;
IF NOT FOUND THEN RAISE EXCEPTION 'Post not found'; END IF;
SELECT pk_user INTO v_fk_user FROM tb_user WHERE identifier = p_user_identifier;
IF NOT FOUND THEN RAISE EXCEPTION 'User not found'; END IF;
-- Insert
INSERT INTO tb_comment (fk_post, fk_user, content)
VALUES (v_fk_post, v_fk_user, p_content)
RETURNING id INTO v_id;
-- Sync
PERFORM fn_sync_tv_comment(v_id);
RETURN v_id;
END;
$$ LANGUAGE plpgsql;
When to Use Database Functions¶
✅ Use when (RECOMMENDED - DEFAULT): - Any production application ⭐ - Need transactional integrity - Want testable business logic - Multiple clients (Python, psql, cron) - Complex validation - Audit logging required
❌ Skip when: - Prototype/demo only (no business logic) - Very simple CRUD (no validation) - Team unfamiliar with PL/pgSQL (train them!)
Recommendation: Make this the DEFAULT in FraiseQL v1 ✅
Combined Pattern: Trinity + Functions (Full Example)¶
Complete Schema¶
-- ============================================
-- COMMAND SIDE: Trinity identifiers
-- ============================================
CREATE TABLE tb_organisation (
pk_organisation SERIAL PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tb_user (
pk_user SERIAL PRIMARY KEY,
fk_organisation INT NOT NULL REFERENCES tb_organisation(pk_organisation),
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tb_post (
pk_post SERIAL PRIMARY KEY,
fk_user INT NOT NULL REFERENCES tb_user(pk_user),
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- QUERY SIDE: Clean UUID + identifier
-- ============================================
CREATE TABLE tv_user (
id UUID PRIMARY KEY,
identifier TEXT UNIQUE NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tv_post (
id UUID PRIMARY KEY,
identifier TEXT UNIQUE NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- SYNC FUNCTIONS
-- ============================================
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS void AS $$
BEGIN
INSERT INTO tv_user (id, identifier, data, updated_at)
SELECT
u.id,
u.identifier,
jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name,
'email', u.email,
'organisation', (
SELECT jsonb_build_object(
'id', o.id::text,
'identifier', o.identifier,
'name', o.name
)
FROM tb_organisation o
WHERE o.pk_organisation = u.fk_organisation
),
'createdAt', u.created_at
),
NOW()
FROM tb_user u
WHERE u.id = p_id
ON CONFLICT (id) DO UPDATE
SET data = EXCLUDED.data, updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION fn_sync_tv_post(p_id UUID) RETURNS void AS $$
BEGIN
INSERT INTO tv_post (id, identifier, data, updated_at)
SELECT
p.id,
p.identifier,
jsonb_build_object(
'id', p.id::text,
'identifier', p.identifier,
'title', p.title,
'content', p.content,
'createdAt', p.created_at,
'author', (
SELECT jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name
)
FROM tb_user u
WHERE u.pk_user = p.fk_user
)
),
NOW()
FROM tb_post p
WHERE p.id = p_id
ON CONFLICT (id) DO UPDATE
SET data = EXCLUDED.data, updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- ============================================
-- MUTATION FUNCTIONS with trinity IDs
-- ============================================
CREATE FUNCTION fn_create_user(
p_organisation_identifier TEXT,
p_identifier TEXT,
p_name TEXT,
p_email TEXT
) RETURNS UUID AS $$
DECLARE
v_fk_organisation INT;
v_id UUID;
BEGIN
-- Resolve by identifier (human-friendly!)
SELECT pk_organisation INTO v_fk_organisation
FROM tb_organisation
WHERE identifier = p_organisation_identifier;
IF NOT FOUND THEN
RAISE EXCEPTION 'Organisation not found: %', p_organisation_identifier;
END IF;
-- Validation
IF p_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$' THEN
RAISE EXCEPTION 'Invalid email format';
END IF;
-- Insert
INSERT INTO tb_user (fk_organisation, identifier, name, email)
VALUES (v_fk_organisation, p_identifier, p_name, p_email)
RETURNING id INTO v_id;
-- Sync
PERFORM fn_sync_tv_user(v_id);
RETURN v_id;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION fn_create_post(
p_user_identifier TEXT,
p_identifier TEXT,
p_title TEXT,
p_content TEXT
) RETURNS UUID AS $$
DECLARE
v_fk_user INT;
v_id UUID;
BEGIN
-- Resolve user by identifier
SELECT pk_user INTO v_fk_user
FROM tb_user
WHERE identifier = p_user_identifier;
IF NOT FOUND THEN
RAISE EXCEPTION 'User not found: %', p_user_identifier;
END IF;
-- Insert
INSERT INTO tb_post (fk_user, identifier, title, content)
VALUES (v_fk_user, p_identifier, p_title, p_content)
RETURNING id INTO v_id;
-- Sync
PERFORM fn_sync_tv_post(v_id);
RETURN v_id;
END;
$$ LANGUAGE plpgsql;
Python API (Clean & Simple)¶
import fraiseql
from fraiseql.types import ID
@fraiseql.type
class Organisation:
id: ID
identifier: str
name: str
@fraiseql.type
class User:
id: ID
identifier: str
name: str
email: str
organisation: Organisation
@fraiseql.type
class Post:
id: ID
identifier: str
title: str
content: str
author: User
# QUERIES
@fraiseql.query
async def user(
info,
id: ID | None = None,
identifier: str | None = None
) -> User | None:
repo = QueryRepository(info.context["db"])
if id:
return await repo.find_one("tv_user", id=id)
elif identifier:
return await repo.find_by_identifier("tv_user", identifier)
raise ValueError("Must provide id or identifier")
# MUTATIONS (trivial - logic in database)
@fraiseql.mutation
async def create_user(
info,
organisation: str,
identifier: str,
name: str,
email: str
) -> User:
db = info.context["db"]
id = await db.fetchval(
"SELECT fn_create_user($1, $2, $3, $4)",
organisation, identifier, name, email
)
return await QueryRepository(db).find_one("tv_user", id=id)
@fraiseql.mutation
async def create_post(
info,
author: str,
identifier: str,
title: str,
content: str
) -> Post:
db = info.context["db"]
id = await db.fetchval(
"SELECT fn_create_post($1, $2, $3, $4)",
author, identifier, title, content
)
return await QueryRepository(db).find_one("tv_post", id=id)
GraphQL Usage¶
# Create post with human-friendly identifiers!
mutation {
createPost(
author: "john-doe", # Username (not UUID!)
identifier: "my-first-post", # Slug
title: "My First Post",
content: "Hello world"
) {
id # UUID returned
identifier # "my-first-post"
title
author {
id
identifier # "john-doe"
name
}
}
}
# Query by identifier
query {
user(identifier: "john-doe") { # Human-friendly!
id
name
organisation {
identifier # "acme-corp"
name
}
}
}
# URL-friendly: /posts/my-first-post
Integration with FraiseQL v1¶
Updated Configuration (Final)¶
from fraiseql import FraiseQLConfig
config = FraiseQLConfig(
# Trinity identifier pattern (DEFAULT in v1)
trinity_identifiers=True,
primary_key_prefix="pk_", # pk_user, pk_post
foreign_key_prefix="fk_", # fk_organisation, fk_user
public_id_column="id", # UUID (exposed in GraphQL)
identifier_column="identifier", # Human-readable
# Mutations as functions (DEFAULT in v1)
mutations_as_functions=True,
mutation_function_prefix="fn_",
sync_function_prefix="fn_sync_tv_",
# Query side
query_view_prefix="tv_",
jsonb_column="data",
)
Updated QueryRepository¶
from fraiseql.types import ID
class QueryRepository:
async def find_one(
self,
view: str,
id: ID | None = None, # By public UUID
identifier: str | None = None # By human identifier
) -> dict | None:
"""Find by UUID or identifier"""
if id:
where = "id = $1"
param = id
elif identifier:
where = "identifier = $1"
param = identifier
else:
raise ValueError("Must provide id or identifier")
result = await self.db.fetchrow(
f"SELECT data FROM {view} WHERE {where}",
param
)
return result["data"] if result else None
async def find_by_identifier(self, view: str, identifier: str) -> dict | None:
"""Convenience method"""
return await self.find_one(view, identifier=identifier)
Summary: Why These Patterns are DEFAULT¶
Trinity Identifiers¶
- ✅ Fast database joins (SERIAL)
- ✅ Secure public API (UUID)
- ✅ Human-friendly URLs (identifier)
- ✅ Clear naming (
pk_*,fk_*,id,identifier) - ✅ GraphQL best practices (just "id")
Mutations as Functions¶
- ✅ Business logic in database (reusable)
- ✅ Automatic transactions
- ✅ Explicit sync calls
- ✅ Testable in SQL
- ✅ Single database round-trip
- ✅ Versioned with migrations
Interview Impact¶
Shows you understand: - Database performance (INT vs UUID joins) - API security (don't expose sequential IDs) - User experience (human-readable identifiers) - Stored procedures (database-first thinking) - Transaction management - Separation of concerns - Production patterns
Perfect for Staff+ interviews ⭐
Next Steps¶
- ✅ Update V1_COMPONENT_PRDS.md with trinity + functions
- ✅ Update V1_DOCUMENTATION_PLAN.md Quick Start
- ✅ Update FRAISEQL_V1_BLUEPRINT.md core patterns
- ✅ Create example migrations showing full pattern
These patterns are now the DEFAULT for FraiseQL v1! 🚀