Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

TierCategorySupportedWhy?
1SQL-ASTPostgreSQL, SQLiteOpen wire protocols allow full AST encoding.
2Document-ASTMongoDB, DynamoDB, QdrantNative 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)

FeatureStatus
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 FeatureWhy It ExistsQAIL Replacement
Parameterized QueriesPrevent string injectionNot needed — Value::Param is a typed AST node, not a string hole
Prepared Statements (for security)Separate SQL from dataNot needed — AST has no SQL text to inject into
Query EscapingSanitize user inputNot needed — values are typed (Value::Text, Value::Int), never interpolated
SQL ValidatorsDetect malformed queriesNot needed — invalid AST won’t compile
LISTEN/NOTIFYPub/sub channelsNot 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

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

MethodSQL 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

MethodDescription
.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

ApproachHow Queries Work
String-basedSQL written as text, parameterized at runtime
Macro-basedDSL macros expand to SQL at compile time
AST-NativeTyped 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

FeatureQAIL Approach
SafetyStructural - no SQL strings to inject
JSONNative json(), json_path() operators
Expressionscoalesce(), concat(), cast() builders
CTEswith_cte() for complex queries
AsyncFull async/await support
Type ValidationColumnType 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

KeywordDescriptionExample
getSELECT queryget users fields *
setUPDATE queryset users values ...
delDELETE querydel users where ...
addINSERT queryadd users values ...
fieldsSelect columnsfields id, email
whereFilter conditionswhere active = true
order bySort resultsorder by name desc
limitLimit rowslimit 10
offsetSkip rowsoffset 20
left joinLeft outer joinleft 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
}
OptionDefaultDescription
idle_timeout10 minStale connections auto-discarded
acquire_timeout30 secMax wait for connection
connect_timeout10 secMax 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);
}
#![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_name
  • get_f64_by_name, get_bool_by_name
  • get_uuid_by_name, get_json_by_name
  • is_null_by_name, column_index

Supported Types

Rust TypePostgreSQL Type
i16/i32/i64INT2/INT4/INT8
f32/f64FLOAT4/FLOAT8
boolBOOLEAN
StringTEXT/VARCHAR
Vec<u8>BYTEA
UuidUUID
TimestampTIMESTAMPTZ
DateDATE
TimeTIME
JsonJSONB
NumericNUMERIC/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

HintDescription
rename table.old -> table.newRename column (not drop+add)
transform expr -> table.colData transformation hint
drop confirm table.colExplicit 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

FieldDescription
migrationUnique name (timestamp_description)
dependsComma-separated list of dependencies
authorAuthor of the migration
createdISO 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

FeatureDescription
Impact AnalysisShows exactly what data will be affected
Pre-Migration BackupOption to backup before destructive changes
Record-Level BackupJSONB-based data backup in database
Shadow DatabaseBlue-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_snapshots table (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

OperationBackup Content
DROP COLUMNColumn values with row IDs
DROP TABLEFull table as JSONB objects
ALTER TYPEOriginal 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

  1. Create Shadow - New database with new schema
  2. Apply Migrations - Run DDL on shadow only
  3. Sync Data - Copy data from primary
  4. Validate - Test your application against shadow
  5. 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

FeatureQAILPrismaSeaORMLiquibase
Schema Migrations
Impact Analysis
Pre-Migration Backup
Record-Level Backup
Shadow Database
True Data Rollback

Best Practices

  1. Always use database backup for production migrations
  2. Test in shadow before promoting
  3. Keep old database for 24-48 hours after promotion
  4. 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:

  1. Scans your codebase for QAIL and raw SQL queries
  2. Detects breaking changes like dropped tables/columns
  3. Shows exact file locations where code needs updating
  4. 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 users
  • set::users → UPDATE users
  • add::users → INSERT INTO users
  • del::users → DELETE FROM users

Raw SQL

  • SELECT ... FROM table
  • INSERT INTO table
  • UPDATE table SET
  • DELETE FROM table

Supported File Types

  • .rs (Rust)
  • .ts (TypeScript)
  • .js (JavaScript)
  • .py (Python)

Breaking Change Types

Change TypeSeverityDescription
DROP TABLE🔴 CriticalTable referenced in code will cause runtime errors
DROP COLUMN🔴 CriticalColumn queries will fail
RENAME🟡 WarningCode needs updating to use new name
TYPE CHANGE🟡 WarningMay cause type mismatch errors

Best Practices

  1. Always run before migrate up

    qail migrate analyze old.qail:new.qail --codebase ./src
    qail migrate up old.qail:new.qail $DATABASE_URL
    
  2. Add to CI/CD pipeline

    - name: Check migration safety
      run: qail migrate analyze $OLD:$NEW --codebase ./src
    
  3. Use with migrate plan for full preview

    qail 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

ActionSQLDescription
FkAction::NoActionNO ACTIONReject if referenced row exists (default)
FkAction::CascadeCASCADEDelete/update child rows
FkAction::SetNullSET NULLSet FK column to NULL
FkAction::SetDefaultSET DEFAULTSet FK column to default value
FkAction::RestrictRESTRICTSame 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

  1. Always validate before migrating

    #![allow(unused)]
    fn main() {
    let schema = parse_qail(&content)?;
    schema.validate()?;
    }
  2. Use Cascade carefully - it can delete more data than expected

  3. 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

  1. Create pool once at application startup
  2. Share via Arc across threads/tasks
  3. Don’t hold connections longer than needed
  4. 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 TypePostgreSQL TypeNotes
StringTEXT, VARCHARUTF-8
i32INT432-bit integer
i64INT8, BIGINT64-bit integer
f64FLOAT8Double precision
boolBOOLEAN
UuidUUID128-bit
TimestampTIMESTAMPMicrosecond precision
DateDATE
TimeTIME
JsonJSON, JSONB
DecimalNUMERICArbitrary 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:

CheckLevelDescription
Missing primary key🔴 ERROREvery table needs a PK
Missing created_at/updated_at⚠️ WARNINGAudit trail columns
_id column without references()⚠️ WARNINGFK integrity
Uppercase table names⚠️ WARNINGUse snake_case
SERIAL vs UUIDℹ️ INFOConsider UUID for distributed
Nullable without defaultℹ️ INFOConsider 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

FlagDescription
-d, --dialectTarget SQL dialect (pg, mysql)
-f, --formatOutput format (sql, ast, json)
-v, --verboseVerbose output
--versionShow version
--helpShow help

API Reference

Full API documentation is generated from source code.

Crates

CrateDescriptionDocs
qail-coreAST, Builder, Parserdocs.rs
qail-pgPostgreSQL driverdocs.rs
qail-wasmWASM bindingsnpm

Generate Local Docs

cargo doc --no-deps --open

Key Types

qail-core

  • QailCmd - Query command builder
  • Operator - Comparison operators
  • SortOrder - ASC/DESC
  • Expr - Expression AST nodes

qail-pg

  • PgDriver - Database connection
  • PgPool - Connection pool
  • PgRow - Result row
  • PgError - Error types

Source Code

View the source on GitHub: