QAIL Documentation
The AST-Native Query Compiler
QAIL compiles typed query ASTs directly to database wire protocols. No SQL strings. No injection surface. Just pure, type-safe queries.
Philosophy: AST = Meaning
If a database doesn’t let us encode semantic intent, we don’t fake it.
QAIL compiles typed query ASTs directly to database wire protocols. No SQL strings. No injection surface.
Supported Databases
| Tier | Category | Supported | Why? |
|---|---|---|---|
| 1 | SQL-AST | PostgreSQL, SQLite | Open wire protocols allow full AST encoding. |
| 2 | Document-AST | MongoDB, DynamoDB, Qdrant | Native AST query structure (BSON/JSON). |
❌ Not Supported
- Oracle, SQL Server, MySQL: Proprietary/Closed protocols.
- Redis: Imperative command model (not a query language).
Quick Example
#![allow(unused)]
fn main() {
use qail_core::{Qail, Operator, SortOrder};
// Build a query with the AST builder
let cmd = Qail::get("users")
.columns(["id", "email", "name"])
.filter("active", Operator::Eq, true)
.order_by("created_at", SortOrder::Desc)
.limit(10);
// Execute with qail-pg driver
let mut driver = PgDriver::connect("localhost", 5432, "user", "db").await?;
let rows = driver.query(&cmd).await?;
}
Current Status (~80% Production Ready)
| Feature | Status |
|---|---|
| SSL/TLS | ✅ |
| SCRAM-SHA-256 Auth | ✅ |
| Connection Pooling | ✅ |
| AST-Native Migrations | ✅ |
| JSON/JSONB Types | ✅ |
| UUID, Timestamps, INTERVAL | ✅ |
| CTEs (WITH) | ✅ |
| DISTINCT ON | ✅ |
| CASE WHEN | ✅ |
| Ergonomic Builders | ✅ |
| qail-lsp (IDE) | ✅ |
| COPY Protocol | ✅ |
| Arrays (Value::Array) | ✅ |
| Transactions (BEGIN/COMMIT/ROLLBACK) | ✅ |
| Query Plan Caching | ✅ |
| Window Functions (OVER) | ✅ |
| Subqueries & EXISTS | ✅ |
| UPSERT (ON CONFLICT) | ✅ |
| RETURNING Clause | ✅ |
| LATERAL JOIN | ✅ |
| Unix Socket & mTLS | ✅ |
| Savepoints | ✅ |
| UNION/INTERSECT/EXCEPT | ✅ |
| TRUNCATE | ✅ |
| Batch Transactions | ✅ |
| Statement Timeout | ✅ |
| EXPLAIN / EXPLAIN ANALYZE | ✅ |
| LOCK TABLE | ✅ |
| Connection Timeout | ✅ |
| Materialized Views | ✅ |
Note: QAIL’s AST-native design eliminates SQL injection by construction — no strings, no injection surface. Query plan caching (
prepare(),pipeline_prepared_fast()) is purely a PostgreSQL performance optimization, not a security measure.
Why Some SQL Features Don’t Exist in QAIL
QAIL speaks AST, not SQL strings. Many traditional SQL “security features” are solutions to string-based problems that don’t exist in an AST-native world:
| SQL Feature | Why It Exists | QAIL Replacement |
|---|---|---|
| Parameterized Queries | Prevent string injection | Not needed — Value::Param is a typed AST node, not a string hole |
| Prepared Statements (for security) | Separate SQL from data | Not needed — AST has no SQL text to inject into |
| Query Escaping | Sanitize user input | Not needed — values are typed (Value::Text, Value::Int), never interpolated |
| SQL Validators | Detect malformed queries | Not needed — invalid AST won’t compile |
| LISTEN/NOTIFY | Pub/sub channels | Not planned — string-based protocol, outside AST scope |
The AST Guarantee
#![allow(unused)]
fn main() {
// SQL String (vulnerable):
let sql = format!("SELECT * FROM users WHERE id = {}", user_input);
// QAIL AST (impossible to inject):
Qail::get("users").filter("id", Operator::Eq, user_input)
// user_input becomes Value::Int(123) or Value::Text("...")
// — never interpolated into a string
}
Getting Help
Installation
Rust (Recommended)
Add QAIL to your Cargo.toml:
[dependencies]
qail-core = "0.9" # AST and Builder
qail-pg = "0.9" # PostgreSQL driver
CLI
Install the QAIL command-line tool:
cargo install qail
JavaScript/TypeScript (WASM)
npm install qail-wasm
Verify Installation
qail --version
# qail 0.9.5
Quick Start
Connect to PostgreSQL
use qail_pg::driver::PgDriver;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect with password (SCRAM-SHA-256)
let mut driver = PgDriver::connect_with_password(
"localhost", 5432, "user", "database", "password"
).await?;
// Or with SSL/TLS
let mut driver = PgDriver::connect(
"localhost", 5432, "user", "database"
).await?;
Ok(())
}
Execute Your First Query
#![allow(unused)]
fn main() {
use qail_core::ast::{QailCmd, Operator};
// Build a SELECT query
let cmd = QailCmd::get("users")
.columns(["id", "email"])
.filter("active", Operator::Eq, true)
.limit(10);
// Execute
let rows = driver.query(&cmd).await?;
for row in rows {
let id: i32 = row.get("id")?;
let email: String = row.get("email")?;
println!("{}: {}", id, email);
}
}
Use Connection Pooling
#![allow(unused)]
fn main() {
use qail_pg::driver::{PgPool, PoolConfig};
let config = PoolConfig::new("localhost", 5432, "user", "db")
.password("secret")
.max_connections(20);
let pool = PgPool::connect(config).await?;
// Acquire connection from pool
let mut conn = pool.acquire().await?;
conn.simple_query("SELECT 1").await?;
// Connection automatically returned when dropped
}
Run Migrations
# Pull current schema from database
qail pull postgres://user:pass@localhost/db > schema.qail
# Create a new version with changes
# (edit schema.qail manually)
# Diff and apply
qail diff old.qail new.qail
qail migrate up old.qail:new.qail postgres://...
AST Builder API
The recommended way to use QAIL. Build queries as typed Rust structs.
Query Types
| Method | SQL Equivalent |
|---|---|
Qail::get() | SELECT |
Qail::add() | INSERT |
Qail::set() | UPDATE |
Qail::del() | DELETE |
SELECT Queries
#![allow(unused)]
fn main() {
use qail_core::{Qail, Operator, SortOrder};
let cmd = Qail::get("users")
.columns(["id", "email", "name"])
.filter("active", Operator::Eq, true)
.order_by("created_at", SortOrder::Desc)
.limit(10)
.offset(20);
}
INSERT Queries
#![allow(unused)]
fn main() {
let cmd = Qail::add("users")
.columns(["email", "name"])
.values(["alice@example.com", "Alice"])
.returning(["id", "created_at"]);
}
UPDATE Queries
#![allow(unused)]
fn main() {
let cmd = Qail::set("users")
.set_value("status", "active")
.set_value("verified_at", "now()")
.where_eq("id", 42);
}
DELETE Queries
#![allow(unused)]
fn main() {
let cmd = Qail::del("users")
.where_eq("id", 42);
}
Builder Methods
| Method | Description |
|---|---|
.columns([...]) | Select specific columns |
.select_all() | SELECT * |
.filter(col, op, val) | WHERE condition |
.where_eq(col, val) | WHERE col = val |
.order_by(col, dir) | ORDER BY |
.limit(n) | LIMIT n |
.offset(n) | OFFSET n |
.left_join(table, on_left, on_right) | LEFT JOIN |
.returning([...]) | RETURNING clause |
Expression Builders
QAIL provides ergonomic builder functions for constructing AST expressions without verbose struct creation.
Import
#![allow(unused)]
fn main() {
use qail_core::ast::builders::*;
}
Column References
#![allow(unused)]
fn main() {
// Named column
col("phone_number")
// Star (*)
star()
}
Aggregate Functions
#![allow(unused)]
fn main() {
// COUNT(*)
count()
// COUNT(DISTINCT column)
count_distinct("phone_number")
// COUNT(*) FILTER (WHERE ...)
count_filter(vec![
eq("direction", "outbound"),
gt("created_at", now_minus("24 hours")),
]).alias("messages_sent_24h")
// Other aggregates
sum("amount")
avg("score")
min("price")
max("quantity")
}
All aggregates support:
.distinct()— Add DISTINCT modifier.filter(conditions)— Add FILTER clause.alias("name")— Add AS alias
Time Functions
#![allow(unused)]
fn main() {
// NOW()
now()
// INTERVAL 'duration'
interval("24 hours")
// NOW() - INTERVAL 'duration' (common pattern)
now_minus("24 hours")
// NOW() + INTERVAL 'duration'
now_plus("7 days")
}
Type Casting
#![allow(unused)]
fn main() {
// expr::type
cast(col("amount"), "float8")
// With alias
cast(col("amount"), "float8").alias("amount_f")
}
CASE WHEN Expressions
#![allow(unused)]
fn main() {
// Simple CASE
case_when(gt("score", 80), text("A"))
.otherwise(text("F"))
.alias("grade")
// Multiple WHEN clauses
case_when(gt("score", 90), text("A"))
.when(gt("score", 80), text("B"))
.when(gt("score", 70), text("C"))
.otherwise(text("F"))
.alias("grade")
}
Condition Helpers
#![allow(unused)]
fn main() {
// Equality
eq("status", "active") // status = 'active'
ne("status", "deleted") // status != 'deleted'
// Comparisons
gt("created_at", now_minus("24 hours")) // created_at > NOW() - INTERVAL '24 hours'
gte("age", 18) // age >= 18
lt("price", 100) // price < 100
lte("quantity", 10) // quantity <= 10
// IN / NOT IN
is_in("status", ["delivered", "read"]) // status IN ('delivered', 'read')
not_in("type", ["spam", "junk"]) // type NOT IN ('spam', 'junk')
// NULL checks
is_null("deleted_at") // deleted_at IS NULL
is_not_null("email") // email IS NOT NULL
// Pattern matching
like("name", "John%") // name LIKE 'John%'
ilike("email", "%@gmail%") // email ILIKE '%@gmail%'
}
Function Calls
#![allow(unused)]
fn main() {
// Generic function
func("MY_FUNC", vec![col("a"), col("b")])
// COALESCE
coalesce(vec![col("nickname"), col("name"), text("Anonymous")])
// NULLIF
nullif(col("value"), int(0))
}
Binary Expressions
#![allow(unused)]
fn main() {
// Arithmetic
binary(col("price"), BinaryOp::Mul, col("quantity"))
// With alias
binary(
cast(col("success"), "float8"),
BinaryOp::Div,
cast(col("total"), "float8")
).alias("success_rate")
}
Literals
#![allow(unused)]
fn main() {
int(42) // Integer literal
float(3.14) // Float literal
text("hello") // String literal (quoted)
}
Complete Example
Here’s a complex analytics query using all the builders:
#![allow(unused)]
fn main() {
use qail_core::ast::builders::*;
let stats = Qail::get("whatsapp_messages")
.columns([
count_distinct("phone_number").alias("total_contacts"),
count().alias("total_messages"),
count_filter(vec![
eq("direction", "outbound"),
gt("created_at", now_minus("24 hours")),
]).alias("messages_sent_24h"),
count_filter(vec![
eq("direction", "inbound"),
eq("status", "received"),
]).alias("unread_messages"),
]);
let cmd = Qail::get("stats")
.with_cte("stats", stats)
.columns([
col("total_contacts"),
col("total_messages"),
case_when(gt("messages_sent_24h", 0),
binary(
cast(col("successful"), "float8"),
BinaryOp::Div,
cast(col("messages_sent_24h"), "float8")
)
).otherwise(float(0.0)).alias("delivery_rate"),
]);
}
This replaces 40+ lines of raw SQL with type-safe, compile-time checked Rust code.
QAIL: The AST-Native Approach
QAIL takes a unique approach to building SQL queries: instead of strings or macros, queries are constructed as a typed Abstract Syntax Tree.
The AST-Native Difference
| Approach | How Queries Work |
|---|---|
| String-based | SQL written as text, parameterized at runtime |
| Macro-based | DSL macros expand to SQL at compile time |
| AST-Native | Typed AST compiles directly to wire protocol |
What QAIL Enables
Native PostgreSQL Features
#![allow(unused)]
fn main() {
use qail_core::{Qail, builders::*};
// Native JSON operators (->, ->>)
json_path("metadata", ["vessel_bookings", "0", "key"])
// COALESCE with type safety
coalesce([col("booking_number"), text("N/A")])
// String concatenation
concat([col("first_name"), text(" "), col("last_name")])
// Type casting
cast(col("total_fare"), "float")
// CASE WHEN expressions
case_when(gt("score", 80), text("pass"))
.otherwise(text("fail"))
}
Full Query Example
A production WhatsApp integration query with JSON access, string concat, and type casts:
#![allow(unused)]
fn main() {
use qail_core::{Qail, Operator, builders::*};
let route = coalesce([
concat([
json_path("o.metadata", ["vessel_bookings", "0", "depart_departure_loc"]),
text(" → "),
json_path("o.metadata", ["vessel_bookings", "0", "depart_arrival_loc"]),
]),
text("Route"),
]).alias("route");
let cmd = Qail::get("orders")
.table_alias("o")
.column_expr(col("o.id"))
.column_expr(coalesce([col("o.booking_number"), text("N/A")]).alias("booking_number"))
.column_expr(cast(col("o.status"), "text").alias("status"))
.column_expr(route)
.column_expr(coalesce([
json_path("o.metadata", ["vessel_bookings", "0", "depart_travel_date"]),
text("TBD")
]).alias("travel_date"))
.filter_cond(cond(json("o.contact_info", "phone"), Operator::Eq, param(1)))
.or_filter_cond(cond(
replace(json("o.contact_info", "phone"), text("+"), text("")),
Operator::Eq,
param(1)
))
.order_desc("o.created_at")
.limit(10);
let orders = pool.fetch_all::<OrderRow>(&cmd).await?;
}
QAIL Highlights
| Feature | QAIL Approach |
|---|---|
| Safety | Structural - no SQL strings to inject |
| JSON | Native json(), json_path() operators |
| Expressions | coalesce(), concat(), cast() builders |
| CTEs | with_cte() for complex queries |
| Async | Full async/await support |
| Type Validation | ColumnType enum with compile-time checks |
ColumnType Validation
QAIL validates types at build time:
#![allow(unused)]
fn main() {
pub enum ColumnType {
Uuid, Text, Varchar(Option<u16>), Int, BigInt,
Serial, BigSerial, Bool, Float, Decimal(Option<(u8,u8)>),
Jsonb, Timestamp, Timestamptz, Date, Time, Bytea,
}
// Compile-time validation
ColumnType::Uuid.can_be_primary_key() // true
ColumnType::Jsonb.can_be_primary_key() // false - caught at build time
ColumnType::Jsonb.supports_indexing() // false - warned before migration
}
When to Use QAIL
QAIL shines for:
- Complex PostgreSQL queries with JSON, CTEs, aggregates
- Type-safe query building with IDE support
- Production systems where safety is critical
- Projects that need advanced SQL features without string literals
Text Syntax
For CLI, LSP, and WASM playground. Parses to AST internally.
Keywords
| Keyword | Description | Example |
|---|---|---|
get | SELECT query | get users fields * |
set | UPDATE query | set users values ... |
del | DELETE query | del users where ... |
add | INSERT query | add users values ... |
fields | Select columns | fields id, email |
where | Filter conditions | where active = true |
order by | Sort results | order by name desc |
limit | Limit rows | limit 10 |
offset | Skip rows | offset 20 |
left join | Left outer join | left join profiles |
Examples
Simple Select
get users fields *
→ SELECT * FROM users
Filtered Query
get users
fields id, email, name
where active = true
order by created_at desc
limit 50
Join Query
get users
inner join bookings
fields id, email, bookings.total
where created_at >= 2024-01-01
Insert
add users values (email = "alice@example.com", name = "Alice")
Update
set users values (status = "active") where id = 42
PostgreSQL Driver
The qail-pg crate provides a native PostgreSQL driver with AST-native wire protocol encoding.
Features
- AST-Native — Direct AST to wire protocol, no SQL strings
- SSL/TLS — Full TLS with mutual TLS (mTLS) support
- SCRAM-SHA-256 — Secure password authentication
- Connection Pooling — Efficient resource management
- COPY Protocol — Bulk insert for high throughput
- Cursors — Stream large result sets
- Transactions — BEGIN/COMMIT/ROLLBACK
Connection
#![allow(unused)]
fn main() {
use qail_pg::PgDriver;
// Trust mode (no password)
let driver = PgDriver::connect("localhost", 5432, "user", "db").await?;
// With password (auto-detects MD5 or SCRAM-SHA-256)
let driver = PgDriver::connect_with_password(
"localhost", 5432, "user", "db", "password"
).await?;
}
SSL/TLS
Standard TLS
#![allow(unused)]
fn main() {
use qail_pg::PgConnection;
let conn = PgConnection::connect_tls("localhost", 5432, "user", "db").await?;
}
Mutual TLS (Client Certificates)
#![allow(unused)]
fn main() {
use qail_pg::{PgConnection, TlsConfig};
let config = TlsConfig {
client_cert_pem: cert_bytes,
client_key_pem: key_bytes,
ca_cert_pem: Some(ca_bytes),
};
let conn = PgConnection::connect_mtls("localhost", 5432, "user", "db", config).await?;
}
AST-Native Queries
#![allow(unused)]
fn main() {
let cmd = Qail::get("users").select_all().limit(10);
// Fetch all rows
let rows = driver.fetch_all(&cmd).await?;
// Fetch one row
let row = driver.fetch_one(&cmd).await?;
// Execute mutation (returns affected rows)
let affected = driver.execute(&cmd).await?;
}
Connection Pooling
#![allow(unused)]
fn main() {
use qail_pg::{PgPool, PoolConfig};
let config = PoolConfig::new("localhost", 5432, "user", "db")
.password("secret")
.max_connections(20)
.min_connections(5);
let pool = PgPool::connect(config).await?;
// Acquire connection (auto-returned when dropped)
let mut conn = pool.acquire().await?;
conn.simple_query("SELECT 1").await?;
// Check idle count
let idle = pool.idle_count().await;
}
Pool Timeout Configuration
#![allow(unused)]
fn main() {
use std::time::Duration;
let config = PoolConfig::new("localhost", 5432, "user", "db")
.idle_timeout(Duration::from_secs(600)) // 10 min
.acquire_timeout(Duration::from_secs(30)) // 30 sec
.connect_timeout(Duration::from_secs(10)); // 10 sec
}
| Option | Default | Description |
|---|---|---|
idle_timeout | 10 min | Stale connections auto-discarded |
acquire_timeout | 30 sec | Max wait for connection |
connect_timeout | 10 sec | Max time to establish new connection |
Bulk Insert (COPY Protocol)
High-performance bulk insert using PostgreSQL’s COPY protocol:
#![allow(unused)]
fn main() {
use qail_core::ast::Value;
let cmd = Qail::add("users").columns(&["name", "email"]);
let rows = vec![
vec![Value::Text("Alice".into()), Value::Text("a@x.com".into())],
vec![Value::Text("Bob".into()), Value::Text("b@x.com".into())],
];
let count = driver.copy_bulk(&cmd, &rows).await?;
// count = 2
}
Cursor Streaming
Stream large result sets in batches:
#![allow(unused)]
fn main() {
let cmd = Qail::get("logs").select_all();
let batches = driver.stream_cmd(&cmd, 1000).await?;
for batch in batches {
for row in batch {
// Process row
}
}
}
Transactions
#![allow(unused)]
fn main() {
use qail_pg::PgConnection;
let mut conn = PgConnection::connect("localhost", 5432, "user", "db").await?;
conn.begin_transaction().await?;
// ... queries ...
conn.commit().await?;
// Or rollback on error
conn.rollback().await?;
}
⚠️ Raw SQL (Discouraged)
execute_raw exists for legacy compatibility but violates AST-native philosophy.
Use AST-native alternatives:
- Transactions:
conn.begin_transaction(),conn.commit(),conn.rollback() - DDL: Use QAIL schema syntax and migrate command
#![allow(unused)]
fn main() {
// ❌ Avoid
driver.execute_raw("BEGIN").await?;
// ✅ Prefer AST-native
let mut conn = pool.acquire().await?;
conn.begin_transaction().await?;
// ... queries ...
conn.commit().await?;
}
Row Decoding
By Index
#![allow(unused)]
fn main() {
let name = row.get_string(0);
let age = row.get_i32(1);
}
By Column Name (Recommended)
#![allow(unused)]
fn main() {
// Safer - column order changes don't break code
let name = row.get_string_by_name("name");
let age = row.get_i32_by_name("age");
let email = row.get_string_by_name("email");
// Check if NULL
if row.is_null_by_name("deleted_at") { ... }
}
Available get_by_name methods:
get_string_by_name,get_i32_by_name,get_i64_by_nameget_f64_by_name,get_bool_by_nameget_uuid_by_name,get_json_by_nameis_null_by_name,column_index
Supported Types
| Rust Type | PostgreSQL Type |
|---|---|
i16/i32/i64 | INT2/INT4/INT8 |
f32/f64 | FLOAT4/FLOAT8 |
bool | BOOLEAN |
String | TEXT/VARCHAR |
Vec<u8> | BYTEA |
Uuid | UUID |
Timestamp | TIMESTAMPTZ |
Date | DATE |
Time | TIME |
Json | JSONB |
Numeric | NUMERIC/DECIMAL |
Migrations
QAIL uses an intent-aware .qail schema format that solves the ambiguity problem of state-based migrations.
The Problem with JSON/State-Based Migrations
// v1: {"users": {"username": "text"}}
// v2: {"users": {"name": "text"}}
Did we rename username → name or delete + add? JSON can’t express intent.
The Solution: .qail Schema Format
# schema.qail - Human readable, intent-aware
table users {
id serial primary_key
name text not_null
email text unique
}
# Migration hints express INTENT
rename users.username -> users.name
Workflow
1. Pull Current Schema
qail pull postgres://user:pass@localhost/db > v1.qail
2. Create New Version
Edit v2.qail with your changes and any migration hints:
table users {
id serial primary_key
name text not_null # was 'username'
email text unique
created_at timestamp not_null
}
rename users.username -> users.name
3. Preview Migration
qail diff v1.qail v2.qail
# Output:
# ALTER TABLE users RENAME COLUMN username TO name;
# ALTER TABLE users ADD COLUMN created_at TIMESTAMP NOT NULL;
4. Apply Migration
qail migrate up v1.qail:v2.qail postgres://...
5. Rollback (if needed)
qail migrate down v1.qail:v2.qail postgres://...
Migration Hints
| Hint | Description |
|---|---|
rename table.old -> table.new | Rename column (not drop+add) |
transform expr -> table.col | Data transformation hint |
drop confirm table.col | Explicit drop confirmation |
Named Migrations
Named migrations provide version-controlled migration files with metadata for better tracking.
Creating a Named Migration
qail migrate create add_user_avatars --depends 002_add_users --author orion
Output:
📝 Creating Named Migration
✓ Created: migrations/20251226071129_add_user_avatars.qail
Migration: 20251226071129_add_user_avatars
Depends: 002_add_users
Author: orion
Migration File Format
-- migration: 20251226071129_add_user_avatars
-- depends: 002_add_users
-- author: orion
-- created: 2025-12-26T07:11:29+08:00
+table avatars {
id UUID primary_key
user_id UUID not_null references(users.id)
url TEXT not_null
}
Metadata Fields
| Field | Description |
|---|---|
migration | Unique name (timestamp_description) |
depends | Comma-separated list of dependencies |
author | Author of the migration |
created | ISO 8601 timestamp |
CLI Options
qail migrate create <name>
-d, --depends <migration> # Dependencies (comma-separated)
-a, --author <name> # Author attribution
Dependency Resolution
QAIL validates dependencies before applying migrations:
- Checks all dependencies exist
- Detects circular dependencies
- Applies in topological order
Data-Safe Migrations
QAIL provides enterprise-grade migration safety features that protect your data during schema changes.
Overview
| Feature | Description |
|---|---|
| Impact Analysis | Shows exactly what data will be affected |
| Pre-Migration Backup | Option to backup before destructive changes |
| Record-Level Backup | JSONB-based data backup in database |
| Shadow Database | Blue-green migrations for zero-downtime |
Phase 1: Impact Analysis & Backup Prompt
When running migrations with destructive operations, QAIL analyzes the impact:
$ qail migrate up old.qail:new.qail postgres://...
🚨 Migration Impact Analysis
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DROP COLUMN users.email → 1,234 values at risk
DROP TABLE sessions → 5,678 rows affected
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total: 6,912 records at risk
Choose an option:
[1] Proceed (I have my own backup)
[2] Backup to files (_qail_snapshots/)
[3] Backup to database (with rollback support)
[4] Cancel migration
Options Explained
- [1] Proceed - Continue without QAIL backup (you manage your own)
- [2] File Backup - Export affected data to
_qail_snapshots/directory - [3] Database Backup - Store data in
_qail_data_snapshotstable (enables true rollback) - [4] Cancel - Abort the migration
Phase 2: Record-Level Database Backup
When you choose option [3], QAIL creates a snapshot table:
-- Automatically created
CREATE TABLE _qail_data_snapshots (
id SERIAL PRIMARY KEY,
migration_version VARCHAR(255) NOT NULL,
table_name VARCHAR(255) NOT NULL,
column_name VARCHAR(255),
row_id TEXT NOT NULL,
value_json JSONB NOT NULL,
snapshot_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
What Gets Backed Up
| Operation | Backup Content |
|---|---|
| DROP COLUMN | Column values with row IDs |
| DROP TABLE | Full table as JSONB objects |
| ALTER TYPE | Original values before cast |
True Data Rollback
After migration, you can restore data:
# Schema rollback (adds column back)
qail migrate down new.qail:old.qail postgres://...
# Data rollback (restores values)
# Coming in future release: qail rollback --data
Phase 3: Shadow Database (Blue-Green)
For zero-downtime migrations, use shadow database mode:
# Step 1: Create shadow, apply migrations, sync data
qail migrate shadow old.qail:new.qail postgres://...
🔄 Shadow Migration Mode
━━━━━━━━━━━━━━━━━━━━━━━━━━
[1/4] Creating shadow database: mydb_shadow ✓
[2/4] Applying migration to shadow... ✓
[3/4] Syncing data from primary to shadow...
✓ users (1,234 rows)
✓ orders (5,678 rows)
✓ Synced 2 tables, 6,912 rows
[4/4] Shadow ready for validation
Shadow URL: postgres://...mydb_shadow
Available Commands:
qail migrate promote → Switch traffic to shadow
qail migrate abort → Drop shadow, keep primary
Shadow Workflow
- Create Shadow - New database with new schema
- Apply Migrations - Run DDL on shadow only
- Sync Data - Copy data from primary
- Validate - Test your application against shadow
- Promote or Abort - Make the decision
Promote (Go Live)
$ qail migrate promote postgres://...
🚀 Promoting Shadow to Primary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[1/3] Renaming mydb → mydb_old_20241226 ✓
[2/3] Renaming mydb_shadow → mydb ✓
[3/3] Keeping old database as backup
✓ Shadow promoted successfully!
Old database preserved as: mydb_old_20241226
To clean up: DROP DATABASE mydb_old_20241226
Abort (Rollback)
$ qail migrate abort postgres://...
🛑 Aborting Shadow Migration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Dropping shadow database: mydb_shadow
✓ Shadow database dropped. Primary unchanged.
Comparison with Other Tools
| Feature | QAIL | Prisma | SeaORM | Liquibase |
|---|---|---|---|---|
| Schema Migrations | ✅ | ✅ | ✅ | ✅ |
| Impact Analysis | ✅ | ❌ | ❌ | ❌ |
| Pre-Migration Backup | ✅ | ❌ | ❌ | ❌ |
| Record-Level Backup | ✅ | ❌ | ❌ | ❌ |
| Shadow Database | ✅ | ❌ | ❌ | ❌ |
| True Data Rollback | ✅ | ❌ | ❌ | ❌ |
Best Practices
- Always use database backup for production migrations
- Test in shadow before promoting
- Keep old database for 24-48 hours after promotion
- Use transactions (QAIL does this automatically)
Configuration
No configuration required! Features are enabled by default when destructive operations are detected.
Migration Impact Analyzer
The Migration Impact Analyzer scans your codebase before running migrations to detect breaking changes that could cause runtime errors.
Why Use It?
Dropping a table or column is easy—but if your code still references it, you’ll get runtime errors. The analyzer:
- Scans your codebase for QAIL and raw SQL queries
- Detects breaking changes like dropped tables/columns
- Shows exact file locations where code needs updating
- Prevents downtime by catching issues before they hit production
Usage
qail migrate analyze old.qail:new.qail --codebase ./src
Example Output
🔍 Migration Impact Analyzer
Schema: old.qail → new.qail
Codebase: ./src
Scanning codebase...
Found 395 query references
⚠️ BREAKING CHANGES DETECTED
Affected files: 1
┌─ DROP TABLE promotions (6 references) ─────────────────────────┐
│ ❌ src/repository/promotion.rs:89 → INSERT INTO promotions (
│ ❌ src/repository/promotion.rs:264 → SELECT COUNT(*) FROM promotions
│ ❌ src/repository/promotion.rs:288 → UPDATE promotions SET
│ ❌ src/repository/promotion.rs:345 → DELETE FROM promotions WHERE
└────────────────────────────────────────────────────────────────┘
What would you like to do?
1. Run anyway (DANGEROUS - will cause 1 runtime errors)
2. Dry-run first (show SQL, don't execute)
3. Let me fix the code first (exit)
Supported Query Patterns
The analyzer detects:
QAIL Queries
get::users→ SELECT from usersset::users→ UPDATE usersadd::users→ INSERT INTO usersdel::users→ DELETE FROM users
Raw SQL
SELECT ... FROM tableINSERT INTO tableUPDATE table SETDELETE FROM table
Supported File Types
.rs(Rust).ts(TypeScript).js(JavaScript).py(Python)
Breaking Change Types
| Change Type | Severity | Description |
|---|---|---|
DROP TABLE | 🔴 Critical | Table referenced in code will cause runtime errors |
DROP COLUMN | 🔴 Critical | Column queries will fail |
RENAME | 🟡 Warning | Code needs updating to use new name |
TYPE CHANGE | 🟡 Warning | May cause type mismatch errors |
Best Practices
-
Always run before
migrate upqail migrate analyze old.qail:new.qail --codebase ./src qail migrate up old.qail:new.qail $DATABASE_URL -
Add to CI/CD pipeline
- name: Check migration safety run: qail migrate analyze $OLD:$NEW --codebase ./src -
Use with
migrate planfor full previewqail migrate plan old.qail:new.qail # See SQL qail migrate analyze old.qail:new.qail # Check codebase qail migrate up old.qail:new.qail $URL # Apply
Foreign Key Validation
QAIL provides compile-time validation for foreign key references, ensuring your schema is consistent before migrations run.
Defining Foreign Keys
Use the builder API to define foreign key constraints:
#![allow(unused)]
fn main() {
use qail_core::migrate::{Column, ColumnType, ForeignKey, FkAction};
let user_id = Column::new("user_id", ColumnType::Uuid)
.references("users", "id")
.on_delete(FkAction::Cascade)
.on_update(FkAction::NoAction);
}
FK Actions
| Action | SQL | Description |
|---|---|---|
FkAction::NoAction | NO ACTION | Reject if referenced row exists (default) |
FkAction::Cascade | CASCADE | Delete/update child rows |
FkAction::SetNull | SET NULL | Set FK column to NULL |
FkAction::SetDefault | SET DEFAULT | Set FK column to default value |
FkAction::Restrict | RESTRICT | Same as NO ACTION but checked immediately |
Schema Validation
Call validate() to check all FK references exist:
#![allow(unused)]
fn main() {
let mut schema = Schema::new();
schema.add_table(Table::new("users")
.column(Column::new("id", ColumnType::Uuid).primary_key()));
schema.add_table(Table::new("posts")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(Column::new("user_id", ColumnType::Uuid)
.references("users", "id")));
// Validate all FK references
match schema.validate() {
Ok(()) => println!("Schema is valid"),
Err(errors) => {
for e in errors {
eprintln!("Error: {}", e);
}
}
}
}
Error Messages
If a FK references a non-existent table or column:
FK error: posts.user_id references non-existent table 'users'
FK error: posts.author_id references non-existent column 'users.author_id'
Best Practices
-
Always validate before migrating
#![allow(unused)] fn main() { let schema = parse_qail(&content)?; schema.validate()?; } -
Use Cascade carefully - it can delete more data than expected
-
Prefer SetNull for optional relationships
#![allow(unused)] fn main() { .references("categories", "id") .on_delete(FkAction::SetNull) }
Connection Pooling
Efficient connection reuse with PgPool.
Configuration
#![allow(unused)]
fn main() {
use qail_pg::driver::{PgPool, PoolConfig};
let config = PoolConfig::new("localhost", 5432, "user", "database")
.password("secret")
.max_connections(20)
.min_connections(5);
}
Creating a Pool
#![allow(unused)]
fn main() {
let pool = PgPool::connect(config).await?;
}
Acquiring Connections
#![allow(unused)]
fn main() {
// This waits if all connections are in use
let mut conn = pool.acquire().await?;
// Use the connection
conn.simple_query("SELECT 1").await?;
// Connection automatically returned to pool when dropped
}
Pool Stats
#![allow(unused)]
fn main() {
// Current idle connections
let idle = pool.idle_count().await;
// Maximum configured connections
let max = pool.max_connections();
}
Best Practices
- Create pool once at application startup
- Share via
Arcacross threads/tasks - Don’t hold connections longer than needed
- Set appropriate pool size (CPU cores × 2 is a good start)
#![allow(unused)]
fn main() {
use std::sync::Arc;
let pool = Arc::new(PgPool::connect(config).await?);
// Clone Arc for each task
let pool_clone = pool.clone();
tokio::spawn(async move {
let conn = pool_clone.acquire().await?;
// ...
});
}
Type System
QAIL provides type conversion between Rust and PostgreSQL.
Supported Types
| Rust Type | PostgreSQL Type | Notes |
|---|---|---|
String | TEXT, VARCHAR | UTF-8 |
i32 | INT4 | 32-bit integer |
i64 | INT8, BIGINT | 64-bit integer |
f64 | FLOAT8 | Double precision |
bool | BOOLEAN | |
Uuid | UUID | 128-bit |
Timestamp | TIMESTAMP | Microsecond precision |
Date | DATE | |
Time | TIME | |
Json | JSON, JSONB | |
Decimal | NUMERIC | Arbitrary precision |
Compile-Time Type Safety
QAIL uses the ColumnType enum for compile-time validation in schema definitions:
#![allow(unused)]
fn main() {
use qail_core::migrate::{Column, ColumnType};
// ✅ Compile-time enforced - no typos possible
Column::new("id", ColumnType::Uuid).primary_key()
Column::new("name", ColumnType::Text).not_null()
Column::new("email", ColumnType::Varchar(Some(255))).unique()
// Available types:
// Uuid, Text, Varchar, Int, BigInt, Serial, BigSerial,
// Bool, Float, Decimal, Jsonb, Timestamp, Timestamptz, Date, Time, Bytea
}
Validation at compile time:
primary_key()validates the type can be a PK (UUID, INT, SERIAL)unique()validates the type supports indexing (not JSONB, BYTEA)
Usage
Reading Values
#![allow(unused)]
fn main() {
use qail_pg::types::{Timestamp, Uuid, Json};
for row in rows {
let id: i32 = row.get("id")?;
let uuid: Uuid = row.get("uuid")?;
let created: Timestamp = row.get("created_at")?;
let data: Json = row.get("metadata")?;
}
}
Temporal Types
#![allow(unused)]
fn main() {
use qail_pg::types::{Timestamp, Date, Time};
// Timestamp with microsecond precision
let ts = Timestamp::from_micros(1703520000000000);
// Date only
let date = Date::from_ymd(2024, 1, 15);
// Time only
let time = Time::from_hms(14, 30, 0);
}
JSON
#![allow(unused)]
fn main() {
use qail_pg::types::Json;
let json = Json("{"key": "value"}".to_string());
}
Custom Types
Implement FromPg and ToPg for custom types:
#![allow(unused)]
fn main() {
use qail_pg::types::{FromPg, ToPg, TypeError};
impl FromPg for MyType {
fn from_pg(bytes: &[u8], oid: u32, format: i16) -> Result<Self, TypeError> {
// Decode from wire format
}
}
impl ToPg for MyType {
fn to_pg(&self) -> (Vec<u8>, u32, i16) {
// Encode to wire format
(bytes, oid, format)
}
}
}
CLI Commands
The qail command-line tool.
Installation
cargo install qail
Commands
qail parse
Parse QAIL text syntax to SQL:
qail parse "get users fields * where active = true"
# SELECT * FROM users WHERE active = true
qail pull
Extract schema from database:
qail pull postgres://user:pass@localhost/db > schema.qail
qail diff
Compare two schemas and show migration commands:
qail diff old.qail new.qail
qail check
Validate a schema file or preview migration safety:
# Validate schema
qail check schema.qail
# ✓ Schema is valid
# Tables: 80
# Columns: 1110
# Indexes: 287
# ✓ 82 primary key(s)
# Check migration safety
qail check old.qail:new.qail
# ✓ Both schemas are valid
# Migration preview: 4 operation(s)
# ✓ 3 safe operation(s)
# ⚠️ 1 reversible operation(s)
qail migrate up
Apply migrations:
qail migrate up old.qail:new.qail postgres://...
qail migrate down
Rollback migrations:
qail migrate down old.qail:new.qail postgres://...
qail migrate plan
Preview migration SQL without executing (dry-run):
qail migrate plan old.qail:new.qail
# 📋 Migration Plan (dry-run)
# ┌─ UP (2 operations) ─────────────────────────────────┐
# │ 1. ALTER TABLE users ADD COLUMN verified BOOLEAN
# │ 2. CREATE INDEX idx_users_email ON users (email)
# └─────────────────────────────────────────────────────┘
# ┌─ DOWN (2 operations) ───────────────────────────────┐
# │ 1. ALTER TABLE users DROP COLUMN verified
# │ 2. DROP INDEX IF EXISTS idx_users_email
# └─────────────────────────────────────────────────────┘
# Save to file
qail migrate plan old.qail:new.qail --output migration.sql
qail migrate analyze
Analyze codebase for breaking changes before migrating:
qail migrate analyze old.qail:new.qail --codebase ./src
# 🔍 Migration Impact Analyzer
# Scanning codebase...
# Found 395 query references
#
# ⚠️ BREAKING CHANGES DETECTED
# ┌─ DROP TABLE promotions (6 references) ─────────────┐
# │ ❌ src/repository/promotion.rs:89 → INSERT INTO...
# │ ❌ src/repository/promotion.rs:264 → SELECT...
# └────────────────────────────────────────────────────┘
qail watch
Watch schema file for changes and auto-generate migrations:
qail watch schema.qail
# 👀 QAIL Schema Watch Mode
# Watching: schema.qail
# Press Ctrl+C to stop
# [14:32:15] ✓ Detected 2 change(s):
# ALTER TABLE users ADD COLUMN avatar_url TEXT
# With database connection
qail watch schema.qail --url postgres://... --auto-apply
qail lint
Check schema for best practices and potential issues:
qail lint schema.qail
# 🔍 Schema Linter
# ⚠ 144 warning(s)
# ℹ 266 info(s)
#
# ⚠ users.customer_id Possible FK column without references()
# → Consider adding '.references("table", "id")' for referential integrity
#
# ⚠ orders Missing updated_at column
# → Add 'updated_at TIMESTAMPTZ not_null' for audit trail
# Strict mode (errors only, for CI)
qail lint schema.qail --strict
Lint Checks:
| Check | Level | Description |
|---|---|---|
| Missing primary key | 🔴 ERROR | Every table needs a PK |
| Missing created_at/updated_at | ⚠️ WARNING | Audit trail columns |
_id column without references() | ⚠️ WARNING | FK integrity |
| Uppercase table names | ⚠️ WARNING | Use snake_case |
| SERIAL vs UUID | ℹ️ INFO | Consider UUID for distributed |
| Nullable without default | ℹ️ INFO | Consider default value |
qail migrate status
View migration history for a database:
qail migrate status postgres://...
# 📋 Migration Status
# Database: mydb
# Migration table: _qail_migrations
# ✓ Migration history table is ready
qail fmt
Format QAIL text:
qail fmt "get users fields *" --indent
Options
| Flag | Description |
|---|---|
-d, --dialect | Target SQL dialect (pg, mysql) |
-f, --format | Output format (sql, ast, json) |
-v, --verbose | Verbose output |
--version | Show version |
--help | Show help |
API Reference
Full API documentation is generated from source code.
Crates
| Crate | Description | Docs |
|---|---|---|
qail-core | AST, Builder, Parser | docs.rs |
qail-pg | PostgreSQL driver | docs.rs |
qail-wasm | WASM bindings | npm |
Generate Local Docs
cargo doc --no-deps --open
Key Types
qail-core
QailCmd- Query command builderOperator- Comparison operatorsSortOrder- ASC/DESCExpr- Expression AST nodes
qail-pg
PgDriver- Database connectionPgPool- Connection poolPgRow- Result rowPgError- Error types
Source Code
View the source on GitHub: