Concepts & Glossary¶
Key concepts and terminology in FraiseQL.
Core Concepts¶
CQRS (Command Query Responsibility Segregation)¶
Separating read and write operations for optimal performance:
flowchart TB
subgraph Client
GQL[GraphQL Client]
end
subgraph FraiseQL["FraiseQL API"]
Q[Queries]
M[Mutations]
end
subgraph ReadPath["Read Path (Optimized)"]
V[(v_* Views<br/>Pre-composed JSONB)]
TV[(tv_* Table Views<br/>Denormalized)]
end
subgraph WritePath["Write Path (Transactional)"]
FN[fn_* Functions<br/>Business Logic]
TB[(tb_* Tables<br/>Normalized)]
end
GQL --> Q
GQL --> M
Q --> V
Q --> TV
M --> FN
FN --> TB
TB -.->|triggers/sync| V
TB -.->|triggers/sync| TV
- Commands (Writes): Mutations that modify data
- Queries (Reads): Queries that fetch data from optimized views
Benefits:
- Optimized read paths with PostgreSQL views
- ACID transactions for writes
See Also:
- CQRS Implementation - Complete CQRS blog example
- Enterprise Patterns - Production CQRS with audit trails
- Independent scaling of reads and writes
JSONB View Pattern¶
FraiseQL's core pattern for GraphQL types - database views return pre-composed JSONB:
Data Flow:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ tb_user │ → │ v_user │ → │ GraphQL │
│ (base table) │ │ (JSONB view) │ │ Response │
│ │ │ │ │ │
│ pk_user: 1 │ │ SELECT │ │ { │
│ id: uuid-123 │ │ jsonb_build_ │ │ "id": "uuid" │
│ identifier: │ │ object( │ │ "identifier": │
│ "alice" │ │ 'id', id, │ │ "alice" │
│ name: "Alice" │ │ 'identifier', │ │ "name": "..." │
│ email: a@b.com │ │ identifier, │ │ } │
│ │ │ 'name', name │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
SQL Example (with Trinity Identifiers):
-- Base table with trinity identifiers
CREATE TABLE tb_user (
pk_user INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- Internal
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL, -- Public API
identifier TEXT UNIQUE NOT NULL, -- Human-readable
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
-- JSONB view exposes only public identifiers
CREATE VIEW v_user AS
SELECT
id, -- Direct column for efficient WHERE filtering (WHERE id = $1)
jsonb_build_object(
'id', id, -- Public UUID (exposed in GraphQL)
'identifier', identifier, -- Human-readable slug (exposed in GraphQL)
'name', name,
'email', email
-- Note: pk_user NOT in view (internal only)
) as data
FROM tb_user;
Python Type Definition:
import fraiseql
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="v_user")
class User:
"""User with trinity identifiers."""
id: ID # Public API identifier (stable, secure)
identifier: str # Human-readable slug (SEO-friendly)
name: str
email: str
# Note: pk_user is NOT exposed in GraphQL type
Why This Pattern?
- ✅ PostgreSQL composes JSONB - One query, no N+1 problems
- ✅ Rust transforms efficiently - Compiled performance for field selection
- ✅ Explicit field control - Only fields in JSONB are accessible
- ✅ Security by design - Can't accidentally expose hidden columns
- ✅ Trinity identifiers - Three ID types for different purposes (see below)
Trinity Identifiers¶
FraiseQL's pattern of using three identifier types per entity for optimal performance and usability:
The Three Identifiers:
CREATE TABLE tb_post (
-- 1. pk_* - Internal primary key (NEVER exposed to GraphQL)
pk_post INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- 2. id - Public API identifier (ALWAYS exposed, stable)
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
-- 3. identifier - Human-readable slug (OPTIONAL, for SEO)
identifier TEXT UNIQUE,
-- Other fields
title TEXT NOT NULL,
content TEXT
);
Purpose of Each Identifier:
| Identifier Type | Purpose | Example | Exposed in API? | Use Case |
|---|---|---|---|---|
| pk_post | Fast integer joins in PostgreSQL | 1234 |
❌ Never | Database performance (JOINs, indexes) |
| id | Stable public API identifier | 550e8400-e29b-41d4-a716-446655440000 |
✅ Always | GraphQL queries, external integrations |
| identifier | Human-readable SEO slug | "my-first-post" |
✅ Optional | URLs, user-facing references |
Why Three Identifiers?
- *Performance (pk_):
- Integer primary keys are faster for JOINs than UUIDs
- Smaller indexes, better cache locality
- Sequential IDs optimize B-tree performance
-
Never exposed to prevent enumeration attacks
-
Stability (id):
- UUIDs don't reveal database size or creation order
- Can be generated client-side (distributed systems)
- Stable even if slug changes
-
Safe for public APIs
-
Usability (identifier):
- SEO-friendly URLs:
/posts/my-first-postvs/posts/550e8400... - Human-readable references
- Can change without breaking API (id stays stable)
- Optional (not all entities need slugs)
View column pattern:
-- Leaf view (nothing references it) - only needs id for filtering
CREATE VIEW v_user AS
SELECT
id, -- For WHERE id = $1 filtering
jsonb_build_object(
'id', id,
'name', name
) as data
FROM tb_user;
-- Referenced view - needs id AND pk_* for parent views to JOIN
CREATE VIEW v_post AS
SELECT
id, -- For WHERE id = $1 filtering
pk_post, -- For parent views to JOIN
jsonb_build_object(
'id', id,
'identifier', identifier,
'title', title,
'content', content
) as data
FROM tb_post;
-- Parent view composing nested data using pk_post
CREATE VIEW v_user_with_posts AS
SELECT
id, -- For WHERE id = $1 filtering
jsonb_build_object(
'id', u.id,
'name', u.name,
'posts', (
SELECT jsonb_agg(p.data)
FROM v_post p
JOIN tb_post tp ON tp.pk_post = p.pk_post
WHERE tp.user_id = tb_user.pk_user
)
) as data
FROM tb_user u;
Rule:
- Always include
id(public identifier) for WHERE filtering - Include
pk_*only if other views need to JOIN to this view - Never include
pk_*in JSONB (internal only)
import fraiseql
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="v_post")
class Post:
id: ID # Public API - stable forever
identifier: str # Human-readable - can change
title: str
content: str
# pk_post NOT exposed - internal only
Querying by Different Identifiers:
# Query by public UUID
query {
post(id: "550e8400-e29b-41d4-a716-446655440000") {
title
}
}
# Query by human-readable identifier
query {
post(identifier: "my-first-post") {
title
}
}
# pk_post is NEVER queryable from GraphQL (security)
Best Practices:
✅ Always use trinity pattern for entities with public APIs ✅ Never expose pk_* in GraphQL types (security risk) ✅ Use id for API contracts (stable, never changes) ✅ Use identifier for URLs (human-friendly, can update) ✅ Index all three for query performance:
CREATE INDEX idx_post_pk ON tb_post(pk_post); -- Primary key (automatic)
CREATE UNIQUE INDEX idx_post_id ON tb_post(id); -- API lookups
CREATE UNIQUE INDEX idx_post_identifier ON tb_post(identifier); -- URL lookups
Projection Tables (tv_*)¶
Pattern: Manually-synced tables that cache pre-composed JSONB for instant GraphQL queries.
Projection tables (tv_*) are regular tables (NOT views!) that store materialized JSONB data:
When to use:
- Read-heavy workloads (10:1+ read:write ratio)
- Large datasets (>100k rows) where view JOINs are too slow
- GraphQL APIs needing sub-millisecond response times
- Acceptable write complexity for massive read performance gains
Architecture (3-layer CQRS):
-- Layer 1: Base tables (normalized, for writes)
CREATE TABLE tb_user (
pk_user INT PRIMARY KEY,
id UUID UNIQUE NOT NULL,
name TEXT,
email TEXT
);
CREATE TABLE tb_post (
pk_post INT PRIMARY KEY,
user_id INT REFERENCES tb_user(pk_user),
title TEXT,
content TEXT
);
-- Layer 2: Views (compose JSONB from base tables)
CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'id', u.id,
'name', u.name,
'posts', (
SELECT jsonb_agg(jsonb_build_object('id', p.id, 'title', p.title))
FROM tb_post p
WHERE p.user_id = u.pk_user
)
) AS data
FROM tb_user u;
-- Layer 3: Projection tables (cache JSONB for fast reads)
CREATE TABLE tv_user (
id UUID PRIMARY KEY,
data JSONB NOT NULL, -- Regular column (NOT GENERATED!)
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sync function: copies v_user → tv_user
CREATE FUNCTION fn_sync_tv_user(p_id UUID) RETURNS VOID AS $$
BEGIN
INSERT INTO tv_user (id, data)
SELECT id, data FROM v_user WHERE id = p_id
ON CONFLICT (id) DO UPDATE SET
data = EXCLUDED.data,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- Mutation explicitly calls sync (CRITICAL!)
CREATE FUNCTION fn_create_user(p_name TEXT, p_email TEXT)
RETURNS JSONB AS $$
DECLARE v_user_id UUID;
BEGIN
INSERT INTO tb_user (name, email)
VALUES (p_name, p_email)
RETURNING id INTO v_user_id;
-- Explicitly sync to projection table
PERFORM fn_sync_tv_user(v_user_id);
RETURN (SELECT data FROM tv_user WHERE id = v_user_id);
END;
$$ LANGUAGE plpgsql;
⚠️ CRITICAL: Explicit Sync Required
Projection tables do NOT auto-update. Every mutation must call sync functions:
-- ✅ CORRECT
CREATE FUNCTION fn_update_user(...) RETURNS JSONB AS $$
BEGIN
UPDATE tb_user SET name = p_name WHERE id = p_id;
PERFORM fn_sync_tv_user(p_id); -- Must call!
RETURN (SELECT data FROM tv_user WHERE id = p_id);
END;
$$;
-- ❌ WRONG - Missing sync!
CREATE FUNCTION fn_update_user_broken(...) RETURNS JSONB AS $$
BEGIN
UPDATE tb_user SET name = p_name WHERE id = p_id;
-- tv_user will have stale data!
RETURN (SELECT data FROM tv_user WHERE id = p_id);
END;
$$;
Benefits:
- ✅ 100-200x faster reads - 0.05-0.5ms (vs 5-10ms for views)
- ✅ Embedded relations - Nested data pre-composed
- ✅ Works with Rust pipeline - JSONB → Rust → HTTP
- ✅ No N+1 queries - Everything in one lookup
Trade-offs:
- ❌ Write complexity - Mutations must call sync functions
- ❌ Storage overhead - Duplicates data (1.5-2x)
- ❌ Manual sync - Developer must remember
- ⚠️ Not for high-write tables - Sync overhead
Python mapping:
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="tv_user", jsonb_column="data")
class User:
id: ID
name: str
posts: list[Post] # Pre-composed!
Common misconception:
-- ❌ WRONG - PostgreSQL can't do this!
CREATE TABLE tv_user (
id UUID PRIMARY KEY,
data JSONB GENERATED ALWAYS AS (
SELECT ... FROM tb_user ... -- Can't reference other tables!
) STORED
);
Where GENERATED ALWAYS works:
-- ✅ Same-row scalar extraction (for indexing)
CREATE TABLE tb_user (
data JSONB,
email TEXT GENERATED ALWAYS AS (lower(data->>'email')) STORED
);
GraphQL Concepts¶
Auto-Documentation¶
FraiseQL automatically extracts field descriptions from your Python code for GraphQL schema documentation.
Supported sources (priority order):
- Inline comments (highest priority)
- Annotated types with string metadata
- Docstring Fields sections (lowest priority)
Example:
import fraiseql
import fraiseql
from typing import Annotated
from fraiseql.types import ID
@fraiseql.type(sql_source="v_user")
class User:
"""User account model.
Fields:
created_at: Account creation timestamp
"""
id: ID # Public API identifier (inline comment - highest priority)
identifier: str # Human-readable username
name: Annotated[str, "User's full name"] # Annotated type
email: str
created_at: datetime # Uses docstring description
Generated GraphQL schema:
type User {
"Public API identifier"
id: ID!
"Human-readable username"
identifier: String!
"User's full name"
name: String!
email: String!
"Account creation timestamp"
createdAt: DateTime!
}
Auto-applied to:
- ✅ Type fields (visible in Apollo Studio)
- ✅ Where clause filter operators (
eq,gt,contains, etc.) - ✅ Input type fields
- ✅ Mutation parameters
- ✅ Specialized type operators (network, LTree, coordinates)
Benefits:
- No separate documentation files to maintain
- Descriptions live next to type definitions
- AI tools (Claude, Copilot) can see context
- Apollo Studio shows helpful field hints
Type¶
Define your data models with trinity identifiers:
import fraiseql
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="v_user")
class User:
"""User type with trinity identifiers."""
id: ID # Public API identifier (always exposed)
identifier: str # Human-readable slug (SEO-friendly)
name: str
email: str
# pk_user is NOT exposed (internal only)
Without trinity pattern (simpler entities):
import fraiseql
import fraiseql
@fraiseql.type(sql_source="v_note")
class Note:
"""Simple note without slug."""
id: int # Can use simple int if no public API needed
title: str
content: str
Query¶
Read operations:
import fraiseql
async def get_users(info) -> list[User]:
"""Get all users."""
db = info.context["db"]
return await db.find(User)
Mutation¶
Write operations (two patterns supported):
Simple pattern (function-based):
import fraiseql
import fraiseql
@fraiseql.mutation
async def create_user(info, input: CreateUserInput) -> User:
"""Simple mutation that returns the type directly."""
db = info.context["db"]
# Call PostgreSQL function with business logic
result = await db.execute_function("fn_create_user", {
"name": input.name,
"email": input.email
})
return await db.find_one("v_user", id=result["id"])
Class-based pattern (with success/failure):
import fraiseql
import fraiseql
@fraiseql.mutation
class CreateUser:
"""Create a new user with explicit success/failure handling."""
input: CreateUserInput
success: CreateUserSuccess
failure: ValidationError
async def resolve(self, info):
db = info.context["db"]
# Call PostgreSQL function - all business logic in database
result = await db.execute_function("fn_create_user", {
"name": self.input.name,
"email": self.input.email
})
# PostgreSQL function returns success/error indicator with user ID
if result["success"]:
user = await db.find_one("v_user", id=result["user_id"])
return CreateUserSuccess(
user=user,
message=result.get("message", "User created")
)
return ValidationError(
message=result["error"],
code=result.get("code", "VALIDATION_ERROR")
)
Corresponding PostgreSQL function:
CREATE OR REPLACE FUNCTION fn_create_user(
p_name TEXT,
p_email TEXT
) RETURNS JSONB AS $$
DECLARE
v_user_id UUID;
BEGIN
-- Validation: Check email format using regex
-- Pattern: local-part@domain.tld (basic RFC 5322 compliance)
IF p_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$' THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Invalid email format',
'code', 'INVALID_EMAIL'
);
END IF;
-- Insert user
INSERT INTO tb_user (name, email)
VALUES (p_name, p_email)
RETURNING id INTO v_user_id;
-- Audit log
INSERT INTO audit_log (action, details)
VALUES ('user_created', jsonb_build_object('user_id', v_user_id));
-- Return success with user data
RETURN jsonb_build_object(
'success', true,
'user', jsonb_build_object(
'id', v_user_id,
'name', p_name,
'email', p_email
),
'message', 'User created successfully'
);
END;
$$ LANGUAGE plpgsql;
Where Input Types¶
FraiseQL automatically generates strongly-typed WhereInput types for filtering with field-specific operators.
Basic operators (all types):
eq/neq- Equality checksin/nin- List membership (NOT IN)isnull- Null checks (true = IS NULL, false = IS NOT NULL)
Numeric operators (Int, Float, Decimal):
gt/gte- Greater than (or equal)lt/lte- Less than (or equal)
String operators:
contains- Substring search (case-sensitive)startswith- Prefix matchendswith- Suffix match
Date/DateTime operators:
- All numeric operators (
gt,gte,lt,lte) in/ninfor specific datesisnullfor optional date fields
Example - basic filtering:
query {
users(where: {
status: { eq: "active" }
age: { gte: 18 }
email: { endswith: "@company.com" }
deletedAt: { isnull: true }
}) {
name
email
}
}
Specialized Type Operators:
1. Coordinates (Geographic Filtering)
query {
stores(where: {
location: {
distance_within: {
center: [37.7749, -122.4194] # San Francisco
radius: 5000 # meters
}
}
}) {
name
address
location
}
}
2. Network Addresses (IP/CIDR Filtering)
query {
servers(where: {
ipAddress: {
inSubnet: "192.168.1.0/24" # CIDR subnet matching
isPrivate: true # RFC 1918 private addresses
isIPv4: true # IPv4 vs IPv6
}
}) {
hostname
ipAddress
}
}
query {
publicServers(where: {
ipAddress: {
inRange: { from: "10.0.0.1", to: "10.0.0.254" }
isPublic: true
NOT: { isLoopback: true }
}
}) {
hostname
}
}
Network classification operators:
inSubnet- IP within CIDR rangeinRange- IP between from/to addressesisPrivate/isPublic- RFC 1918 detectionisIPv4/isIPv6- IP versionisLoopback- 127.0.0.1 or ::1isMulticast- Multicast addressesisBroadcast- 255.255.255.255isLinkLocal- 169.254.0.0/16 or fe80::/10isDocumentation- RFC 3849/5737 rangesisReserved- Reserved/unspecified (0.0.0.0, ::)isCarrierGrade- CGN range (100.64.0.0/10)isSiteLocal- Site-local IPv6 (deprecated)isUniqueLocal- Unique local IPv6 (fc00::/7)isGlobalUnicast- Global unicast addresses
3. LTree (Hierarchical Paths)
query {
categories(where: {
path: {
ancestor_of: "Electronics.Computers.Laptops" # All ancestor categories
nlevel_gte: 2 # At least 2 levels deep
}
}) {
name
path
}
}
query {
subcategories(where: {
path: {
descendant_of: "Electronics" # All subcategories
matches_lquery: "Electronics.*" # Pattern matching
}
}) {
name
}
}
LTree hierarchical operators:
ancestor_of- Path is ancestor of targetdescendant_of- Path is descendant of targetmatches_lquery- Pattern match with wildcardsmatches_ltxtquery- Text search (AND/OR/NOT)nlevel_eq/nlevel_gt/nlevel_gte/nlevel_lt/nlevel_lte- Path depth filtering
Logical Operators (All WhereInput Types)
Combine filters with AND, OR, NOT for complex queries:
query {
users(where: {
AND: [
{ status: { eq: "active" } }
{ OR: [
{ role: { eq: "admin" } }
{ AND: [
{ role: { eq: "editor" } }
{ verified: { eq: true } }
]
}
]
}
]
}) {
name
role
}
}
Nested array filtering:
query {
users(where: {
posts: { # Filter parent by nested array properties
AND: [
{ status: { eq: "published" } }
{ views: { gte: 1000 } }
]
}
}) {
name
posts {
title
views
}
}
}
How it works:
- FraiseQL inspects your type fields
- Generates appropriate filter class per field type
- Creates
TypeWhereInputwith logical operators - Converts GraphQL input to SQL WHERE clauses
- PostgreSQL executes with proper type casting
Example generated type:
import fraiseql
from fraiseql.types import ID
# Your type definition
@fraiseql.type(sql_source="v_server")
class Server:
id: ID
hostname: str
ip_address: NetworkAddress # Special type
port: int
location: Coordinate # Special type
# FraiseQL auto-generates:
class ServerWhereInput:
id: UUIDFilter | None
hostname: StringFilter | None
ip_address: NetworkAddressFilter | None # Rich operators!
port: IntFilter | None
location: CoordinateFilter | None # Distance queries!
AND: list[ServerWhereInput] | None
OR: list[ServerWhereInput] | None
NOT: ServerWhereInput | None
Benefits:
- ✅ Type-safe filtering - No runtime query errors
- ✅ Field-specific operators -
containsfor strings,gtfor numbers - ✅ Specialized types - Network, geographic, hierarchical queries
- ✅ Logical operators - Complex AND/OR/NOT combinations
- ✅ Apollo autocomplete - All operators visible in IDE
- ✅ SQL injection safe - Parameterized queries always
Connection¶
Relay-style cursor-based pagination (built-in):
import fraiseql
from fraiseql import connection
from fraiseql.types.generic import Connection
@connection(
node_type=User,
default_page_size=20,
max_page_size=100
)
async def users(info, first: int | None = None, after: str | None = None) -> Connection[User]:
"""Get paginated users - pagination handled automatically."""
# Framework calls db.paginate() automatically
# Returns Connection with nodes, pageInfo, totalCount
Configuration options:
node_type: The type being paginated (required)view_name: Database view (defaults tov_<function_name>)default_page_size: Default results per page (default: 20)max_page_size: Maximum allowed page size (default: 100)cursor_field: Field for cursor (default: "id")include_total_count: Include total count (default: True)
Returned Connection type includes:
nodes: List of items (User[])pageInfo: Pagination info (hasNextPage, hasPreviousPage, startCursor, endCursor)totalCount: Total number of items (optional)
Database Concepts¶
View¶
Read-optimized database views that compose JSONB for GraphQL:
Simple view (without trinity pattern):
CREATE VIEW v_note AS
SELECT
id,
jsonb_build_object(
'id', id,
'title', title,
'content', content,
'created_at', created_at
) as data
FROM tb_note
WHERE deleted_at IS NULL;
View with trinity identifiers:
CREATE VIEW v_user AS
SELECT
id, -- For WHERE id = $1 filtering
jsonb_build_object(
'id', id, -- Public UUID
'identifier', identifier, -- Human-readable slug
'name', name,
'email', email,
'created_at', created_at
) as data
FROM tb_user
WHERE deleted_at IS NULL;
*View that will be referenced by others (includes pk_):
CREATE VIEW v_post AS
SELECT
id, -- For WHERE id = $1 filtering
pk_post, -- For parent views to JOIN
jsonb_build_object(
'id', id,
'title', title,
'content', content
) as data
FROM tb_post;
Key points:
- Always include
idas direct column for efficient WHERE filtering - Include
pk_*only if other views need to JOIN/reference this view - Never include
pk_*in JSONB data column (internal only) datacolumn contains complete GraphQL response- Only fields in JSONB are exposed to GraphQL
Materialized View¶
Pre-computed aggregations:
CREATE MATERIALIZED VIEW user_stats AS
SELECT
user_id,
COUNT(*) as post_count,
MAX(created_at) as last_post_at
FROM posts
GROUP BY user_id;
Index¶
Performance optimization:
CREATE INDEX idx_users_email ON users(email);
Performance Concepts¶
Query Complexity¶
Limiting query depth and breadth:
from fraiseql import ComplexityConfig
config = ComplexityConfig(
max_complexity=1000,
max_depth=10
)
APQ (Automatic Persisted Queries)¶
Cache GraphQL queries by SHA-256 hash to reduce bandwidth and improve performance.
How it works:
- First request: Client sends full query + SHA-256 hash
- Server: Stores query in cache, returns result
- Subsequent requests: Client sends only hash
- Server: Retrieves query from cache, executes, returns result
Benefits:
- ✅ Bandwidth reduction - 90%+ for large queries (send 64-char hash vs full query)
- ✅ Faster uploads - Especially on mobile networks
- ✅ Query optimization - Server can optimize cached queries
- ✅ Works with Rust pipeline - PostgreSQL → JSONB → Rust → HTTP (no slowdown)
See Also:
- APQ Multi-tenant Example - APQ with tenant isolation
Configuration:
Memory backend (single instance):
from fraiseql import FraiseQLConfig
config = FraiseQLConfig(
apq_storage_backend="memory", # Default - LRU cache
apq_cache_size=1000 # Max cached queries
)
PostgreSQL backend (multi-instance):
config = FraiseQLConfig(
apq_storage_backend="postgresql",
apq_storage_schema="apq_cache", # Schema for cache table
apq_cache_ttl=3600 # TTL in seconds (optional)
)
# Creates table:
# CREATE TABLE apq_cache.persisted_queries (
# query_hash TEXT PRIMARY KEY,
# query_text TEXT NOT NULL,
# created_at TIMESTAMPTZ DEFAULT NOW(),
# last_used TIMESTAMPTZ DEFAULT NOW()
# );
Client usage (Apollo Client):
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 }).concat(
new HttpLink({ uri: 'http://localhost:8000/graphql' })
);
const client = new ApolloClient({
cache: new InMemoryCache(),
link,
});
// First query: sends full query + hash
// Subsequent queries: sends only hash
const { data } = await client.query({
query: GET_USERS,
// Apollo automatically handles APQ protocol
});
Server logs:
[APQ] Cache miss - storing query hash: 5d41402abc4b2a76b9719d911017c592
[APQ] Cache hit - executing query from hash: 5d41402abc4b2a76b9719d911017c592
When to use:
- Large, complex queries (>1KB)
- Mobile applications (limited bandwidth)
- Multi-instance deployments (use PostgreSQL backend)
- Production APIs with repeated queries
Storage backend comparison:
| Feature | Memory Backend | PostgreSQL Backend |
|---|---|---|
| Multi-instance | ❌ No | ✅ Yes (shared cache) |
| Persistence | ❌ Lost on restart | ✅ Survives restarts |
| Performance | ✅ Fastest | ⚠️ Network overhead |
| Setup | ✅ Zero config | ⚠️ Requires migration |
| Use case | Single instance | Multi-instance/production |
Monitoring:
from fraiseql.monitoring import apq_metrics
# Check APQ cache statistics
stats = await apq_metrics.get_stats()
print(f"Cache hits: {stats.hits}")
print(f"Cache misses: {stats.misses}")
print(f"Hit rate: {stats.hit_rate:.2%}")
print(f"Cached queries: {stats.total_queries}")
See also:
Rust JSON Pipeline¶
FraiseQL's exclusive architecture: PostgreSQL → Rust → HTTP
Traditional frameworks:
PostgreSQL → Rows → ORM → Python objects → JSON serialize → Response
╰────────── Python overhead ──────────╯
FraiseQL:
PostgreSQL → JSONB → Rust transform → HTTP Response
╰────── 7-10x faster ──────╯
Why Rust?
- Compiled performance - No Python serialization overhead
- 7-10x faster JSON processing - Rust handles field selection
- Zero-copy path - Direct bytes to HTTP response
- No GIL contention - Parallel request processing
Architectural advantage:
- PostgreSQL composes JSONB once (no N+1 queries)
- Rust selects only requested fields (respects GraphQL query)
- No Python in the hot path (compiled speed for every request)
Security Concepts¶
Explicit Field Exposure (Security by Architecture)¶
FraiseQL prevents accidental data leaks through explicit JSONB view contracts:
The ORM security problem:
# Traditional ORM - ALL columns loaded
class User(Base):
id = Column(Integer)
email = Column(String)
password_hash = Column(String) # Oops! Sensitive!
api_key = Column(String) # Oops! Sensitive!
# Easy to forget excluding fields
# One mistake = data leak
FraiseQL's explicit whitelisting:
-- Only safe fields in JSONB view
CREATE VIEW v_user AS
SELECT
pk_user,
jsonb_build_object(
'id', id,
'email', email
-- password_hash CANNOT be queried
-- api_key CANNOT be queried
-- Impossible to accidentally expose!
) as data
FROM tb_user;
Security benefits:
- ✅ Whitelist-only - Only fields in JSONB are accessible
- ✅ Database-enforced - PostgreSQL is the security boundary
- ✅ Two-layer protection - SQL view + Python type
- ✅ No accidental exposure - Can't query fields not in view
- ✅ Trinity protection - pk_* never exposed (prevents enumeration)
Recursion Depth Protection¶
Views define maximum nesting depth structurally:
-- View defines max depth (no circular references possible)
CREATE VIEW v_user AS
SELECT
pk_user,
jsonb_build_object(
'id', id,
'posts', (
SELECT jsonb_agg(jsonb_build_object(
'id', p.id,
'title', p.title
-- NO 'author' field here
-- Recursion IMPOSSIBLE
))
FROM tb_post p
WHERE p.user_id = tb_user.id
LIMIT 100 -- Array size limit
)
) as data
FROM tb_user;
Protection:
- ✅ Fixed depth - Attackers can't exceed view definition
- ✅ No middleware needed - GraphQL schema validates automatically
- ✅ Array size limits - LIMIT clauses prevent huge responses
- ✅ One query - No N+1 bomb attacks possible
Field-Level Authorization¶
Control access at the field level:
import fraiseql
from fraiseql import field, authorized
@field
@authorized(roles=["admin"])
def sensitive_field(user: User, info) -> str:
"""Only admins can access this field."""
return user.sensitive_data
Rate Limiting¶
Prevent abuse:
from fraiseql.auth import RateLimitConfig
rate_limit = RateLimitConfig(
requests_per_minute=100
)
Introspection Control¶
Disable schema introspection in production:
config = FraiseQLConfig(
introspection_enabled=False
)