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

🤝 Contributing & Support

We welcome issue reports on GitHub! Please provide detailed descriptions to help us reproduce and fix the problem. We aim to address critical issues within 1-5 business days.

Caution

Alpha Software: QAIL is currently in alpha. While we strive for stability, the API is evolving to ensure it remains ergonomic and truly AST-native. Do not use in production environments yet.

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 Types

QAIL v0.14.2 provides 100% PostgreSQL expression coverage. All expression types are native AST nodes that encode directly to wire protocol bytes.

Coverage

CategoryCoverage
Expressions100%
DML (SELECT, INSERT, UPDATE, DELETE)100%
DDL (CREATE, DROP, ALTER)100%

Basic Expressions

Column Reference

#![allow(unused)]
fn main() {
use qail_core::ast::Expr;

// Simple column
let expr = Expr::Named("email".to_string());
// → email

// With alias
let expr = Expr::Aliased {
    name: "users.email".to_string(),
    alias: "user_email".to_string(),
};
// → users.email AS user_email
}

Literals

#![allow(unused)]
fn main() {
use qail_core::ast::{Expr, Value};

let expr = Expr::Literal(Value::Int(42));
// → 42

let expr = Expr::Literal(Value::String("hello".into()));
// → 'hello'
}

Aggregate Functions

#![allow(unused)]
fn main() {
use qail_core::ast::{Expr, AggregateFunc};

let expr = Expr::Aggregate {
    func: AggregateFunc::Count,
    col: "*".into(),
    distinct: false,
    filter: None,
    alias: Some("total".into()),
};
// → COUNT(*) AS total
}

With FILTER Clause (v0.14.2+)

#![allow(unused)]
fn main() {
let expr = Expr::Aggregate {
    func: AggregateFunc::Sum,
    col: "amount".into(),
    distinct: false,
    filter: Some(vec![condition]),  // WHERE condition
    alias: Some("filtered_sum".into()),
};
// → SUM(amount) FILTER (WHERE ...) AS filtered_sum
}

Window Functions

#![allow(unused)]
fn main() {
use qail_core::ast::{Expr, WindowFrame, FrameBound};

let expr = Expr::Window {
    func: "SUM".into(),
    params: vec![Expr::Named("amount".into())],
    partition: vec!["department".into()],
    order: vec![order_spec],
    frame: Some(WindowFrame::Rows {
        start: FrameBound::UnboundedPreceding,
        end: FrameBound::CurrentRow,
    }),
    alias: Some("running_total".into()),
};
// → SUM(amount) OVER (
//     PARTITION BY department
//     ORDER BY date
//     ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
//   ) AS running_total
}

CASE Expressions

#![allow(unused)]
fn main() {
let expr = Expr::Case {
    when_clauses: vec![
        (condition1, Value::String("A".into())),
        (condition2, Value::String("B".into())),
    ],
    else_value: Some(Value::String("C".into())),
    alias: Some("grade".into()),
};
// → CASE WHEN ... THEN 'A' WHEN ... THEN 'B' ELSE 'C' END AS grade
}

New in v0.14.2

Array Constructor

#![allow(unused)]
fn main() {
let expr = Expr::ArrayConstructor {
    elements: vec![
        Expr::Named("col1".into()),
        Expr::Named("col2".into()),
    ],
    alias: Some("arr".into()),
};
// → ARRAY[col1, col2] AS arr
}

Row Constructor

#![allow(unused)]
fn main() {
let expr = Expr::RowConstructor {
    elements: vec![
        Expr::Named("id".into()),
        Expr::Named("name".into()),
    ],
    alias: Some("person".into()),
};
// → ROW(id, name) AS person
}

Subscript (Array Access)

#![allow(unused)]
fn main() {
let expr = Expr::Subscript {
    expr: Box::new(Expr::Named("tags".into())),
    index: Box::new(Expr::Literal(Value::Int(1))),
    alias: Some("first_tag".into()),
};
// → tags[1] AS first_tag
}

Collation

#![allow(unused)]
fn main() {
let expr = Expr::Collate {
    expr: Box::new(Expr::Named("name".into())),
    collation: "C".into(),
    alias: None,
};
// → name COLLATE "C"
}

Field Access (Composite Types)

#![allow(unused)]
fn main() {
let expr = Expr::FieldAccess {
    expr: Box::new(Expr::Named("address".into())),
    field: "city".into(),
    alias: Some("city".into()),
};
// → (address).city AS city
}

Type Casting

#![allow(unused)]
fn main() {
let expr = Expr::Cast {
    expr: Box::new(Expr::Named("id".into())),
    target_type: "TEXT".into(),
    alias: None,
};
// → id::TEXT
}

JSON Access

#![allow(unused)]
fn main() {
let expr = Expr::JsonAccess {
    column: "data".into(),
    path_segments: vec![
        ("user".into(), false),  // ->
        ("name".into(), true),   // ->>
    ],
    alias: Some("name".into()),
};
// → data->'user'->>'name' AS name
}

GROUP BY Modes

ModeSQLStatus
GroupByMode::SimpleGROUP BY a, b
GroupByMode::RollupGROUP BY ROLLUP(a, b)
GroupByMode::CubeGROUP BY CUBE(a, b)
GroupByMode::GroupingSetsGROUP BY GROUPING SETS ((a, b), (c))✓ v0.14.2
#![allow(unused)]
fn main() {
use qail_core::ast::GroupByMode;

// GROUPING SETS
let mode = GroupByMode::GroupingSets(vec![
    vec!["year".into(), "month".into()],
    vec!["year".into()],
    vec![],  // grand total
]);
// → GROUP BY GROUPING SETS ((year, month), (year), ())
}

DDL Actions

ActionSQLStatus
Action::MakeCREATE TABLE
Action::DropDROP TABLE
Action::IndexCREATE INDEX
Action::CreateViewCREATE VIEW AS✓ v0.14.2
Action::DropViewDROP VIEW✓ v0.14.2
#![allow(unused)]
fn main() {
use qail_core::ast::{Qail, Action};

// Create view
let mut cmd = Qail::get("orders")
    .columns(["customer_id", "SUM(amount) AS total"])
    .group_by(["customer_id"]);
cmd.action = Action::CreateView;
cmd.table = "customer_totals".into();
// → CREATE VIEW customer_totals AS SELECT ...
}

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
  • Zero-Alloc — Reusable buffers, no heap allocation per query
  • LRU Statement Cache — Bounded cache (100 max), auto-evicts
  • 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

Statement Cache (LRU)

Prepared statements are cached automatically for best performance.

#![allow(unused)]
fn main() {
// Cache is bounded (default: 100 statements)
// Auto-evicts least recently used when full

let (size, capacity) = driver.cache_stats();  // (42, 100)

// Manual clear if needed
driver.clear_cache();
}
MethodDescription
fetch_all()Uses cache (4,500+ q/s)
fetch_all_uncached()Skips cache
cache_stats()Returns (current, max)
clear_cache()Frees all cached statements

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

Redis Driver

“Redis stores time — QAIL decides.”

The qail-redis crate provides a unified Qail AST interface for Redis operations.

Installation

[dependencies]
qail-redis = "0.14"

Quick Start

use qail_redis::{RedisDriver, RedisExt};
use qail_core::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut driver = RedisDriver::connect("127.0.0.1", 6379).await?;
    
    // Unified Qail AST - same pattern as PostgreSQL/Qdrant
    driver.execute(&Qail::redis_set("session:123", b"data".to_vec()).redis_ex(3600)).await?;
    
    let value = driver.get_str("session:123").await?;
    println!("Value: {:?}", value);
    
    Ok(())
}

Unified API

Redis uses the same Qail type as PostgreSQL and Qdrant:

DatabaseConstructorPurpose
PostgreSQLQail::get("users")Query facts
QdrantQail::search("products")Search meaning
RedisQail::redis_get("session")Retrieve time-bound data

Available Commands

String Commands

#![allow(unused)]
fn main() {
// GET key
Qail::redis_get("mykey")

// SET key value
Qail::redis_set("mykey", b"value".to_vec())

// SET with TTL (seconds)
Qail::redis_set("mykey", b"value".to_vec()).redis_ex(3600)

// SET only if not exists
Qail::redis_set("mykey", b"value".to_vec()).redis_nx()

// SET only if exists
Qail::redis_set("mykey", b"value".to_vec()).redis_xx()
}

Counter Commands

#![allow(unused)]
fn main() {
// INCR key
Qail::redis_incr("counter")

// DECR key  
Qail::redis_decr("counter")
}

Key Commands

#![allow(unused)]
fn main() {
// DEL key
Qail::redis_del("mykey")

// EXISTS key
Qail::redis_exists("mykey")

// TTL key
Qail::redis_ttl("mykey")

// EXPIRE key seconds
Qail::redis_expire("mykey", 60)
}

Other Commands

#![allow(unused)]
fn main() {
// PING
Qail::redis_ping()
}

RedisExt Trait

The RedisExt trait provides fluent methods for Redis-specific options:

#![allow(unused)]
fn main() {
use qail_redis::RedisExt;

// Chain TTL onto SET
Qail::redis_set("key", b"value".to_vec())
    .redis_ex(3600)  // EX seconds
    .redis_nx()       // Only if not exists
}

Connection Pooling

#![allow(unused)]
fn main() {
use qail_redis::{RedisPool, PoolConfig};

let config = PoolConfig::new("127.0.0.1", 6379)
    .max_connections(10);

let pool = RedisPool::new(config);

// Get a connection from the pool
let mut conn = pool.get().await?;
conn.set("key", b"value").await?;
}

Native RESP3 Protocol

qail-redis implements native RESP3 protocol encoding/decoding:

  • Zero string parsing: Commands are encoded directly from AST
  • Full RESP3 support: Booleans, doubles, maps, nulls
  • Efficient: Direct wire protocol writes

The QAIL Vision

“Postgres stores facts, Qdrant stores meaning, Redis stores time — QAIL decides.”

With qail-redis, you now have a unified API across all three database paradigms:

#![allow(unused)]
fn main() {
// Facts (PostgreSQL)
Qail::get("users").filter("active", Eq, true)

// Meaning (Qdrant)
Qail::search("products").vector(&embedding).limit(10)

// Time (Redis)
Qail::redis_set("session:123", session_data).redis_ex(3600)
}

Same Qail type. Same philosophy. Different backends.

Migrations

QAIL supports two migration workflows:

  1. Schema-Diff (State-Based): Compare standard schema files (good for evolving production DBs)
  2. File-Based (Sequential): Apply .qail files from migrations/ directory (good for hybrid setups)

1. Schema-Diff Workflow (State-Based)

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://...

2. File-Based Workflow (Sequential)

For hybrid projects or simple setups, you can use sequential .qail files in the migrations/ directory.

Structure

migrations/
  ├── 001_initial_schema.up.qail
  ├── 001_initial_schema.down.qail
  ├── 002_add_users.up.qail
  └── 002_add_users.down.qail

Applying Migrations

# Applies all pending .up.qail files
qail migrate apply

Generating from Sync Rules

Hybrid projects can auto-generate migrations for sync triggers:

qail sync generate
# Creates: migrations/00X_qail_sync_triggers.up.qail

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

Prevents runtime errors by scanning your codebase before running migrations.

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 AST and raw SQL queries
  2. Detects breaking changes like dropped tables/columns
  3. Shows exact file:line locations with code snippets
  4. Prevents downtime by catching issues before production

Usage

qail migrate analyze old.qail:new.qail --codebase ./src

Real-World Example

Testing against a production codebase:

🔍 Migration Impact Analyzer

  Schema: 001_initial_schema.up.qail → breaking_change.qail
  Codebase: ~/api.fortunebali.com/src

Scanning codebase...
🔍 Analyzing files...
   ├── 🦀 main.rs (AST: 60 refs)
   └── 1 files analyzed

  Found 60 query references

⚠️  BREAKING CHANGES DETECTED

Affected files: 1

┌─ DROP TABLE admin_otps (11 references) ─────────────────────────┐
│ ❌ main.rs:397 → Qail::del("admin_otps")
│ ❌ main.rs:402 → Qail::add("admin_otps")
│ ❌ main.rs:403 → .columns(["email", "code_hash", "expires_at"])
│ ... and 8 more
└──────────────────────────────────────────────────────────────────┘

┌─ DROP TABLE inquiries (11 references) ─────────────────────────┐
│ ❌ main.rs:238 → Qail::add("inquiries")
│ ❌ main.rs:239 → .columns(["name", "email", ...])
│ ... and 9 more
└──────────────────────────────────────────────────────────────────┘

┌─ DROP COLUMN portfolio.status (2 references) ─────────────────┐
│ ❌ main.rs:179 → uses status in .columns(["id" +8])
│ ⚠️  RAW SQL main.rs:225 → "SELECT id, title, status FROM..."
└──────────────────────────────────────────────────────────────────┘

What would you like to do?
  1. Run anyway (DANGEROUS - will cause 5 runtime errors)
  2. Dry-run first (show SQL, don't execute)
  3. Let me fix the code first (exit)

Dual-Mode Scanning

ModeBadgeDetection
Rust AST🦀Full syntax tree analysis for Qail::get(), Qail::add(), etc.
Regex📘📍🐍Pattern matching for raw SQL in TypeScript, JavaScript, Python

The analyzer auto-detects file types and uses the most appropriate scanning method.

Rollback Safety Analysis

Warning

Data-Destructive Changes Cannot Be Rolled Back!

Some migrations are irreversible. The analyzer identifies:

ChangeRollback Safe?Why
ADD COLUMN✅ YesCan DROP COLUMN
DROP COLUMNNoData lost permanently
DROP TABLENoData lost permanently
RENAME✅ YesCan rename back
ADD INDEX✅ YesCan drop index
TRUNCATENoData lost permanently

Breaking Change Types

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

CI/CD Integration

For GitHub Actions, use --ci flag for annotations:

- name: Check migration safety
  run: qail migrate analyze $OLD:$NEW --codebase ./src --ci

This outputs GitHub Actions annotations that appear inline in PR diffs:

::error file=src/main.rs,line=225,title=Breaking Change::Column 'portfolio.status' is being dropped but referenced here

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. 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
    
  3. Handle irreversible changes carefully

    • Backup data before DROP TABLE or DROP COLUMN
    • Consider soft-delete (add deleted_at column) instead of hard delete

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?;
    // ...
});
}

Hybrid Architecture

QAIL bridges the gap between relational data (PostgreSQL) and vector search (Qdrant) using a robust Outbox Pattern.

Overview

Instead of dual-writing to both databases (which is prone to inconsistencies), QAIL uses a transactional outbox approach:

  1. Transactional Write: You write to your PostgreSQL tables.
  2. Auto-Trigger: A database trigger captures the change and writes a “sync event” to the _qail_queue table in the same transaction.
  3. Async Worker: The qail worker daemon polls the queue and reliably syncs data to Qdrant.

Architecture Diagram

Setup

1. Initialize Hybrid Project

qail init
# Select "3. Hybrid (PostgreSQL + Qdrant)" when prompted

This creates a migrations/001_qail_queue.up.qail file defining the _qail_queue table.

2. Configure Sync Rules

Define which tables should sync to Qdrant in qail.toml:

[[sync]]
source_table = "products"           # Table to watch
trigger_column = "description"      # Only sync if this column changes (optional optimization)
target_collection = "products_search" # Qdrant collection name
embedding_model = "candle:bert-base"  # (Coming soon)

3. Generate Triggers

Run the command to generate PostgreSQL triggers based on your rules:

qail sync generate
# Creates migrations/002_qail_sync_triggers.up.qail

This generates triggers that:

  • INSERT: Adds UPSERT event to queue.
  • UPDATE: Adds UPSERT event (only if trigger_column changed).
  • DELETE: Adds DELETE event to queue.

4. Apply Migrations

Apply the migration files to your database:

qail migrate apply

5. Start Worker

Start the background worker to process the queue:

qail worker

Resilience

The qail worker is designed for production reliability:

  • At-Least-Once Delivery: Events are only removed from the queue after successful sync to Qdrant.
  • Circuit Breaker: If Qdrant goes down, the worker pauses and retries with exponential backoff (up to 30s).
  • Per-Item Error Handling: A single reduced item won’t crash the worker; it will be marked with an error and retried later.
  • Batch Processing: Updates are sent to Qdrant in batches for high throughput.

The _qail_queue Table

ColumnTypeDescription
idSERIALSequence ID for ordering
ref_tableTEXTSource table name
ref_idTEXTPrimary key of the source row
operationTEXTUPSERT or DELETE
payloadJSONBFull row data (snapshot)
statusTEXTpending, processing, failed
retry_countINTNumber of failed attempts
error_messageTEXTLast error message

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)
    }
}
}

Compile-Time Type Safety

New in v0.14.20 — Full Diesel-like type checking for QAIL queries

QAIL now supports compile-time type validation through generated schema files, similar to Diesel but with AST-native architecture.

Quick Start

1. Generate Schema

qail types schema.qail -o src/schema.rs

2. Use Type-Safe Builders

#![allow(unused)]
fn main() {
use crate::schema::users;

let query = Qail::get(users::TABLE)
    .typed_column(users::id())
    .typed_column(users::email())
    .typed_eq(users::active(), true)  // Compile-time: active must be bool
    .typed_gt(users::age(), 18);       // Compile-time: age must be numeric
}

Schema Generation

Input: schema.qail

table users {
    id          uuid primary_key
    email       text not_null unique
    name        text
    active      boolean default(true)
    age         integer
    created_at  timestamptz default(now())
}

Output: schema.rs

#![allow(unused)]
fn main() {
pub mod users {
    use qail_core::typed::{TypedColumn, Table};
    
    pub const TABLE: &str = "users";
    
    pub fn id() -> TypedColumn<uuid::Uuid> {
        TypedColumn::new("id")
    }
    
    pub fn email() -> TypedColumn<String> {
        TypedColumn::new("email")
    }
    
    pub fn active() -> TypedColumn<bool> {
        TypedColumn::new("active")
    }
    
    pub fn age() -> TypedColumn<i32> {
        TypedColumn::new("age")
    }
}
}

Type-Safe Methods

MethodDescriptionExample
typed_eq(col, val)Type-safe equalitytyped_eq(users::active(), true)
typed_ne(col, val)Type-safe not-equaltyped_ne(users::status(), "banned")
typed_gt(col, val)Type-safe greater-thantyped_gt(users::age(), 18)
typed_lt(col, val)Type-safe less-thantyped_lt(users::balance(), 0.0)
typed_gte(col, val)Greater-than or equaltyped_gte(users::score(), 100)
typed_lte(col, val)Less-than or equaltyped_lte(users::priority(), 5)
typed_column(col)Add typed columntyped_column(users::email())

SQL to Rust Type Mapping

SQL TypeRust Type
uuiduuid::Uuid
text, varcharString
integer, int4i32
bigint, int8i64
smallint, int2i16
boolean, boolbool
real, float4f32
double precision, float8f64
numeric, decimalf64
timestamptz, timestampchrono::DateTime<Utc>
datechrono::NaiveDate
jsonb, jsonserde_json::Value
byteaVec<u8>

Reserved Keywords

Rust reserved keywords are automatically escaped:

Column NameGenerated Function
typefn r#type()
fnfn r#fn()
structfn r#struct()

Compile-Time Errors

Type mismatches are caught at compile time:

#![allow(unused)]
fn main() {
// ✅ Compiles - active is bool
query.typed_eq(users::active(), true);

// ❌ Compile error - age is i32, not string
query.typed_eq(users::age(), "eighteen");
// error[E0277]: the trait bound `&str: ColumnValue<i32>` is not satisfied
}

Integration with Existing Code

Type-safe methods can be mixed with dynamic methods:

#![allow(unused)]
fn main() {
let query = Qail::get(users::TABLE)
    .typed_eq(users::active(), true)  // Type-safe
    .filter("created_at", Operator::Gte, "2024-01-01")  // Dynamic
    .typed_column(users::email());
}

CLI Commands

The qail command-line tool.

Installation

cargo install qail

Commands

qail init

Initialize a new QAIL project with interactive setup:

qail init
# 🪝 QAIL Project Initialization
# Project name: my_app
# Select database mode:
#   1. PostgreSQL only
#   2. Qdrant only
#   3. Hybrid (PostgreSQL + Qdrant)
# ...

Generates qail.toml and necessary migration files.

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 apply

Apply file-based migrations from migrations/ directory:

qail migrate apply
# → Found 1 migrations to apply
# ✓ Connected to qail_test
#   → 001_qail_queue.up.qail... ✓
# ✓ All migrations applied successfully!

Reads qail.toml for database connection if not provided via --url.

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 sync generate

Generate trigger migrations from [[sync]] rules in qail.toml:

qail sync generate
# → Generating sync triggers...
# ✓ Created migrations/002_qail_sync_triggers.up.qail

Used in Hybrid mode to automatically create PostgreSQL triggers that push changes to the _qail_queue table.

qail worker

Start the background worker to sync data from PostgreSQL to Qdrant:

qail worker --interval 1000 --batch 100
# 👷 QAIL Hybrid Worker v0.14.12
# 🔌 Qdrant: Connected (localhost:6334)
# 🐘 Postgres: Connected (5 connections)
# 
# [2026-01-02 10:00:00] 🔄 Syncing... (pending: 0)

Options:

  • --interval <ms>: Polling interval (default: 1000ms)
  • --batch <size>: Batch size for sync (default: 100)

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 exec

Execute type-safe QAIL statements against a database:

# Inline QAIL execution
qail exec "get users fields id, email where active = true" --url postgres://...
qail exec "add users fields name, email values 'Alice', 'a@test.com'" --url postgres://... --tx

# From file
qail exec -f seed.qail --url postgres://...

# Dry-run (preview generated SQL)
qail exec "get users fields *" --dry-run
# 📋 Parsed 1 QAIL statement(s)
# 🔍 DRY-RUN MODE - Generated SQL:
# Statement 1:
#   SELECT * FROM users
# No changes made.

Options:

  • -f, --file <FILE>: Path to .qail file with statements (one per line)
  • -u, --url <URL>: Database connection URL
  • --tx: Wrap all statements in a transaction
  • --dry-run: Preview generated SQL without executing

Features:

  • Type-safe execution via QAIL AST (driver.execute(ast))
  • Batch execution (multiple statements per file)
  • Transaction support with automatic rollback on error
  • Comments supported (# and --)

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: