Database Overview
SQLite Guide for FraiseQL
Introduction
Section titled “Introduction”SQLite is the best choice for FraiseQL development because it requires zero setup and perfectly mirrors production behavior:
- Instant Setup: Single file, zero configuration, works everywhere
- Perfect for Development: Identical schema and queries to PostgreSQL/MySQL
- In-Memory Testing: Create isolated test databases instantly
- Embedded Deployments: Ship database with your application
- ~15 WHERE Operators: Fewer operators, but sufficient for most GraphQL queries
- JSON Support: Native
json_object(),json_array()functions (SQLite 3.9+) - ACID Transactions: Despite simplicity, maintains consistency
Core Architecture
Section titled “Core Architecture”Single JSON Data Column Pattern
Section titled “Single JSON Data Column Pattern”Like PostgreSQL and MySQL, FraiseQL views expose entities as single JSON columns named data:
-- Every v_* view returns:-- 1. Metadata columns (id, is_current, etc.)-- 2. Single JSON column named 'data' containing complete entity
SELECT id, is_current, dataFROM v_userWHERE id = ?;Trinity Pattern: TEXT UUID + INTEGER PKs
Section titled “Trinity Pattern: TEXT UUID + INTEGER PKs”FraiseQL requires three identity columns per base table: pk_{entity} (internal PK), id (public UUID), and identifier (human-readable domain key). SQLite adapts the canonical pattern as follows:
CREATE TABLE tb_user ( pk_user INTEGER PRIMARY KEY AUTOINCREMENT, -- Internal, fast FKs id TEXT NOT NULL UNIQUE, -- Public, exposed in GraphQL (UUID stored as TEXT) identifier TEXT NOT NULL UNIQUE, -- Human-readable key (e.g. email, username, slug) name TEXT NOT NULL, deleted_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE tb_post ( pk_post INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, identifier TEXT NOT NULL UNIQUE, -- Human-readable key (e.g. slug, order number) fk_user INTEGER NOT NULL REFERENCES tb_user(pk_user) ON DELETE CASCADE, title TEXT NOT NULL, deleted_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
-- Foreign key enforcement (CRITICAL - default is OFF in SQLite)PRAGMA foreign_keys = ON;Mutation Response Type
Section titled “Mutation Response Type”SQLite has no composite types or stored procedures. Instead of returning a mutation_response composite (as PostgreSQL, MySQL, and SQL Server do), SQLite mutations use MutationStrategy::DirectSql — the Rust runtime executes INSERT/UPDATE/DELETE with a RETURNING clause and constructs the response envelope automatically.
The response shape is identical to other databases:
{ "status": "success", "message": "User created successfully", "entity_id": "550e8400-e29b-41d4-a716-446655440000", "entity_type": "User", "entity": { "id": "...", "name": "John Doe" }}You do not need to define a mutation_response type in SQLite. The @fraiseql.mutation decorator works — the runtime handles the dispatch difference transparently. For complex validation logic, use INSTEAD OF triggers (see Mutation Triggers below) or application-level mutations.
View Structure: v_* (Regular Views)
Section titled “View Structure: v_* (Regular Views)”Views are the source truth for read operations:
-- File: 02411_v_user.sqlCREATE VIEW v_user ASSELECT u.id, u.deleted_at IS NULL AS is_current, json_object( 'id', u.id, 'identifier', u.identifier, 'name', u.name, 'created_at', u.created_at, 'updated_at', u.updated_at ) AS dataFROM tb_user uWHERE u.deleted_at IS NULL;Nested Views (One-to-Many Relationships)
Section titled “Nested Views (One-to-Many Relationships)”-- File: 02412_v_user_with_posts.sqlCREATE VIEW v_user_with_posts ASSELECT u.id, u.deleted_at IS NULL AS is_current, json_object( 'id', u.id, 'identifier', u.identifier, 'name', u.name, 'posts', COALESCE( ( SELECT json_group_array( json_object('id', p.id, 'identifier', p.identifier, 'title', p.title) ) FROM tb_post p WHERE p.fk_user = u.pk_user AND p.deleted_at IS NULL ), json_array() ) ) AS dataFROM tb_user uWHERE u.deleted_at IS NULL;Materialized Views: tv_* (Optional for Heavy Reads)
Section titled “Materialized Views: tv_* (Optional for Heavy Reads)”SQLite is single-writer, so materialized views are mainly useful for caching query results:
-- File: 02414_tv_user.sqlCREATE TABLE IF NOT EXISTS tv_user ( id TEXT PRIMARY KEY, is_current BOOLEAN DEFAULT 1, data TEXT NOT NULL, -- JSON stored as TEXT (SQLite has no JSONB type) updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_synced_at TIMESTAMP NULL, sync_count INTEGER DEFAULT 0, is_stale BOOLEAN DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_tv_user_is_current ON tv_user(is_current);Refresh Triggers (called after INSERT/UPDATE):
CREATE TRIGGER trg_user_after_insertAFTER INSERT ON tb_userFOR EACH ROWBEGIN INSERT OR REPLACE INTO tv_user (id, is_current, data) SELECT id, is_current, data FROM v_user WHERE id = NEW.id;END;Mutation Triggers
Section titled “Mutation Triggers”SQLite uses INSTEAD OF triggers for mutation logic (no stored procedures):
-- File: 03311_create_user.sqlCREATE TRIGGER trg_user_createINSTEAD OF INSERT ON v_userWHEN NEW.id IS NOT NULL AND NEW.identifier IS NOT NULLBEGIN SELECT CASE WHEN EXISTS(SELECT 1 FROM tb_user WHERE identifier = NEW.identifier AND deleted_at IS NULL) THEN RAISE(ABORT, 'conflict:identifier') END;
INSERT INTO tb_user (id, identifier, name) VALUES (NEW.id, NEW.identifier, NEW.name);END;Alternative: Application-Level Mutations
For more complex mutations, handle them in application code rather than triggers:
def create_user(db, payload: dict) -> dict: identifier = payload.get('identifier') if not identifier: return {'status': 'failed:validation', 'message': 'identifier is required', 'entity': None}
existing = db.execute( 'SELECT id FROM tb_user WHERE identifier = ? AND deleted_at IS NULL', (identifier,) ).fetchone()
if existing: return {'status': 'conflict:duplicate_identifier', 'message': 'Identifier already in use', 'entity': None}
new_id = str(uuid.uuid4()) db.execute( 'INSERT INTO tb_user (id, identifier, name) VALUES (?, ?, ?)', (new_id, identifier, payload.get('name')) ) db.commit()
result = db.execute('SELECT data FROM v_user WHERE id = ?', (new_id,)).fetchone() return {'status': 'success', 'message': 'User created', 'entity': json.loads(result[0])}Configuration: Essential Pragmas
Section titled “Configuration: Essential Pragmas”-- File: 00001_pragmas.sqlPRAGMA foreign_keys = ON; -- CRITICAL: enable foreign key enforcementPRAGMA journal_mode = WAL; -- Better concurrent readsPRAGMA cache_size = -64000; -- 64MB cachePRAGMA temp_store = MEMORY; -- Speed up temp tablesPRAGMA synchronous = NORMAL; -- Balance safety vs speedPRAGMA optimize; -- Query optimization hintsANALYZE; -- Enable query planner statisticsTesting with In-Memory Databases
Section titled “Testing with In-Memory Databases”SQLite’s in-memory mode is perfect for isolated test databases:
import sqlite3import pytest
@pytest.fixturedef test_db(): """Create fresh in-memory SQLite database for each test""" conn = sqlite3.connect(':memory:') conn.row_factory = sqlite3.Row conn.execute('PRAGMA foreign_keys = ON')
with open('db/0_schema/init.sql', 'r') as f: conn.executescript(f.read())
yield conn conn.close()
def test_create_user(test_db): test_db.execute( 'INSERT INTO tb_user (id, identifier, name) VALUES (?, ?, ?)', ('550e8400-e29b-41d4-a716-446655440000', 'test@example.com', 'Test User') ) test_db.commit()
result = test_db.execute( 'SELECT data FROM v_user WHERE identifier = ?', ('test@example.com',) ).fetchone() assert result is not NoneTroubleshooting
Section titled “Troubleshooting””database is locked” Error
Section titled “”database is locked” Error”-- Increase busy timeoutPRAGMA busy_timeout = 5000; -- Wait 5 seconds before failingSQLite allows only one writer at a time. If you see this error frequently, consider migrating to PostgreSQL for production.
Foreign Key Constraint Fails
Section titled “Foreign Key Constraint Fails”-- MUST be first statement after opening connectionPRAGMA foreign_keys = ON;View Returns NULL Instead of Empty Array
Section titled “View Returns NULL Instead of Empty Array”-- Problem: json_group_array returns NULL when no rows-- Solution: Use COALESCESELECT COALESCE( json_group_array(id), json_array()) FROM tb_post WHERE fk_user = 999;JSON Functions Not Available
Section titled “JSON Functions Not Available”-- Check SQLite version (must be 3.9.0 or higher)SELECT sqlite_version();Limitations and Workarounds
Section titled “Limitations and Workarounds”| Limitation | Workaround |
|---|---|
| Single concurrent writer | Use application-level locking; reads are concurrent |
| No JSONB type | JSON works fine; slightly slower but same patterns |
| No composite types | Use JSON structures in documentation |
| No stored procedures | Use INSTEAD OF triggers or application logic |
| Max ~15 WHERE operators | Use simpler queries; application-level filtering for complex logic |
Performance Benchmarks
Section titled “Performance Benchmarks”| Query Type | Latency | Notes |
|---|---|---|
| Single entity (v_*) | 0.3-0.8ms | Index lookup on id |
| List query (100 rows) | 2-5ms | With is_current filter |
| Nested JSON (3 levels) | 5-20ms | Depends on aggregation |
| Materialized view (tv_*) | 0.5-1ms | Pre-computed cache |
| Analytics view (va_*) | 10-100ms | GROUP BY aggregation |
Optimization Tips:
- Always enable WAL mode for concurrent read performance
- Use in-memory
:memory:databases for testing - Create indexes on frequently-filtered columns
- Use
tv_*(materialized views) for frequently-accessed complex objects
Multi-Transport Support
Section titled “Multi-Transport Support”FraiseQL generates transport-aware views for SQLite:
- JSON-shaped views (GraphQL + REST) use
json_group_array(...) - Row-shaped views (gRPC) use standard
SELECTwith typed columns
All three transports are supported on SQLite.
See Also
Section titled “See Also”PostgreSQL Guide
SQLite Troubleshooting