QAIL Documentation
The AST-Native Query Compiler
QAIL compiles typed query ASTs directly to database wire protocols. No SQL strings. No injection surface. Just pure, type-safe queries.
Philosophy: AST = Meaning
If a database doesn’t let us encode semantic intent, we don’t fake it.
QAIL compiles typed query ASTs directly to database wire protocols. No SQL strings. No injection surface.
Supported Databases
| Tier | Category | Supported | Why? |
|---|---|---|---|
| 1 | SQL-AST | PostgreSQL, SQLite | Open wire protocols allow full AST encoding. |
| 2 | Document-AST | MongoDB, DynamoDB, Qdrant | Native AST query structure (BSON/JSON). |
❌ Not Supported
- Oracle, SQL Server, MySQL: Proprietary/Closed protocols.
- Redis: Imperative command model (not a query language).
Quick Example
#![allow(unused)]
fn main() {
use qail_core::{Qail, Operator, SortOrder};
// Build a query with the AST builder
let cmd = Qail::get("users")
.columns(["id", "email", "name"])
.filter("active", Operator::Eq, true)
.order_by("created_at", SortOrder::Desc)
.limit(10);
// Execute with qail-pg driver
let mut driver = PgDriver::connect("localhost", 5432, "user", "db").await?;
let rows = driver.query(&cmd).await?;
}
Current Status (~80% Production Ready)
| Feature | Status |
|---|---|
| SSL/TLS | ✅ |
| SCRAM-SHA-256 Auth | ✅ |
| Connection Pooling | ✅ |
| AST-Native Migrations | ✅ |
| JSON/JSONB Types | ✅ |
| UUID, Timestamps, INTERVAL | ✅ |
| CTEs (WITH) | ✅ |
| DISTINCT ON | ✅ |
| CASE WHEN | ✅ |
| Ergonomic Builders | ✅ |
| qail-lsp (IDE) | ✅ |
| COPY Protocol | ✅ |
| Arrays (Value::Array) | ✅ |
| Transactions (BEGIN/COMMIT/ROLLBACK) | ✅ |
| Query Plan Caching | ✅ |
| Window Functions (OVER) | ✅ |
| Subqueries & EXISTS | ✅ |
| UPSERT (ON CONFLICT) | ✅ |
| RETURNING Clause | ✅ |
| LATERAL JOIN | ✅ |
| Unix Socket & mTLS | ✅ |
| Savepoints | ✅ |
| UNION/INTERSECT/EXCEPT | ✅ |
| TRUNCATE | ✅ |
| Batch Transactions | ✅ |
| Statement Timeout | ✅ |
| EXPLAIN / EXPLAIN ANALYZE | ✅ |
| LOCK TABLE | ✅ |
| Connection Timeout | ✅ |
| Materialized Views | ✅ |
Note: QAIL’s AST-native design eliminates SQL injection by construction — no strings, no injection surface. Query plan caching (
prepare(),pipeline_prepared_fast()) is purely a PostgreSQL performance optimization, not a security measure.
Why Some SQL Features Don’t Exist in QAIL
QAIL speaks AST, not SQL strings. Many traditional SQL “security features” are solutions to string-based problems that don’t exist in an AST-native world:
| SQL Feature | Why It Exists | QAIL Replacement |
|---|---|---|
| Parameterized Queries | Prevent string injection | Not needed — Value::Param is a typed AST node, not a string hole |
| Prepared Statements (for security) | Separate SQL from data | Not needed — AST has no SQL text to inject into |
| Query Escaping | Sanitize user input | Not needed — values are typed (Value::Text, Value::Int), never interpolated |
| SQL Validators | Detect malformed queries | Not needed — invalid AST won’t compile |
| LISTEN/NOTIFY | Pub/sub channels | Not planned — string-based protocol, outside AST scope |
The AST Guarantee
#![allow(unused)]
fn main() {
// SQL String (vulnerable):
let sql = format!("SELECT * FROM users WHERE id = {}", user_input);
// QAIL AST (impossible to inject):
Qail::get("users").filter("id", Operator::Eq, user_input)
// user_input becomes Value::Int(123) or Value::Text("...")
// — never interpolated into a string
}
Getting Help
🤝 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
Rust (Recommended)
Add QAIL to your Cargo.toml:
[dependencies]
qail-core = "0.9" # AST and Builder
qail-pg = "0.9" # PostgreSQL driver
CLI
Install the QAIL command-line tool:
cargo install qail
JavaScript/TypeScript (WASM)
npm install qail-wasm
Verify Installation
qail --version
# qail 0.9.5
Quick Start
Connect to PostgreSQL
use qail_pg::driver::PgDriver;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect with password (SCRAM-SHA-256)
let mut driver = PgDriver::connect_with_password(
"localhost", 5432, "user", "database", "password"
).await?;
// Or with SSL/TLS
let mut driver = PgDriver::connect(
"localhost", 5432, "user", "database"
).await?;
Ok(())
}
Execute Your First Query
#![allow(unused)]
fn main() {
use qail_core::ast::{QailCmd, Operator};
// Build a SELECT query
let cmd = QailCmd::get("users")
.columns(["id", "email"])
.filter("active", Operator::Eq, true)
.limit(10);
// Execute
let rows = driver.query(&cmd).await?;
for row in rows {
let id: i32 = row.get("id")?;
let email: String = row.get("email")?;
println!("{}: {}", id, email);
}
}
Use Connection Pooling
#![allow(unused)]
fn main() {
use qail_pg::driver::{PgPool, PoolConfig};
let config = PoolConfig::new("localhost", 5432, "user", "db")
.password("secret")
.max_connections(20);
let pool = PgPool::connect(config).await?;
// Acquire connection from pool
let mut conn = pool.acquire().await?;
conn.simple_query("SELECT 1").await?;
// Connection automatically returned when dropped
}
Run Migrations
# Pull current schema from database
qail pull postgres://user:pass@localhost/db > schema.qail
# Create a new version with changes
# (edit schema.qail manually)
# Diff and apply
qail diff old.qail new.qail
qail migrate up old.qail:new.qail postgres://...
AST Builder API
The recommended way to use QAIL. Build queries as typed Rust structs.
Query Types
| Method | SQL Equivalent |
|---|---|
Qail::get() | SELECT |
Qail::add() | INSERT |
Qail::set() | UPDATE |
Qail::del() | DELETE |
SELECT Queries
#![allow(unused)]
fn main() {
use qail_core::{Qail, Operator, SortOrder};
let cmd = Qail::get("users")
.columns(["id", "email", "name"])
.filter("active", Operator::Eq, true)
.order_by("created_at", SortOrder::Desc)
.limit(10)
.offset(20);
}
INSERT Queries
#![allow(unused)]
fn main() {
let cmd = Qail::add("users")
.columns(["email", "name"])
.values(["alice@example.com", "Alice"])
.returning(["id", "created_at"]);
}
UPDATE Queries
#![allow(unused)]
fn main() {
let cmd = Qail::set("users")
.set_value("status", "active")
.set_value("verified_at", "now()")
.where_eq("id", 42);
}
DELETE Queries
#![allow(unused)]
fn main() {
let cmd = Qail::del("users")
.where_eq("id", 42);
}
Builder Methods
| Method | Description |
|---|---|
.columns([...]) | Select specific columns |
.select_all() | SELECT * |
.filter(col, op, val) | WHERE condition |
.where_eq(col, val) | WHERE col = val |
.order_by(col, dir) | ORDER BY |
.limit(n) | LIMIT n |
.offset(n) | OFFSET n |
.left_join(table, on_left, on_right) | LEFT JOIN |
.returning([...]) | RETURNING clause |
Expression 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
| Category | Coverage |
|---|---|
| Expressions | 100% |
| 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
| Mode | SQL | Status |
|---|---|---|
GroupByMode::Simple | GROUP BY a, b | ✓ |
GroupByMode::Rollup | GROUP BY ROLLUP(a, b) | ✓ |
GroupByMode::Cube | GROUP BY CUBE(a, b) | ✓ |
GroupByMode::GroupingSets | GROUP 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
| Action | SQL | Status |
|---|---|---|
Action::Make | CREATE TABLE | ✓ |
Action::Drop | DROP TABLE | ✓ |
Action::Index | CREATE INDEX | ✓ |
Action::CreateView | CREATE VIEW AS | ✓ v0.14.2 |
Action::DropView | DROP 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
| Approach | How Queries Work |
|---|---|
| String-based | SQL written as text, parameterized at runtime |
| Macro-based | DSL macros expand to SQL at compile time |
| AST-Native | Typed AST compiles directly to wire protocol |
What QAIL Enables
Native PostgreSQL Features
#![allow(unused)]
fn main() {
use qail_core::{Qail, builders::*};
// Native JSON operators (->, ->>)
json_path("metadata", ["vessel_bookings", "0", "key"])
// COALESCE with type safety
coalesce([col("booking_number"), text("N/A")])
// String concatenation
concat([col("first_name"), text(" "), col("last_name")])
// Type casting
cast(col("total_fare"), "float")
// CASE WHEN expressions
case_when(gt("score", 80), text("pass"))
.otherwise(text("fail"))
}
Full Query Example
A production WhatsApp integration query with JSON access, string concat, and type casts:
#![allow(unused)]
fn main() {
use qail_core::{Qail, Operator, builders::*};
let route = coalesce([
concat([
json_path("o.metadata", ["vessel_bookings", "0", "depart_departure_loc"]),
text(" → "),
json_path("o.metadata", ["vessel_bookings", "0", "depart_arrival_loc"]),
]),
text("Route"),
]).alias("route");
let cmd = Qail::get("orders")
.table_alias("o")
.column_expr(col("o.id"))
.column_expr(coalesce([col("o.booking_number"), text("N/A")]).alias("booking_number"))
.column_expr(cast(col("o.status"), "text").alias("status"))
.column_expr(route)
.column_expr(coalesce([
json_path("o.metadata", ["vessel_bookings", "0", "depart_travel_date"]),
text("TBD")
]).alias("travel_date"))
.filter_cond(cond(json("o.contact_info", "phone"), Operator::Eq, param(1)))
.or_filter_cond(cond(
replace(json("o.contact_info", "phone"), text("+"), text("")),
Operator::Eq,
param(1)
))
.order_desc("o.created_at")
.limit(10);
let orders = pool.fetch_all::<OrderRow>(&cmd).await?;
}
QAIL Highlights
| Feature | QAIL Approach |
|---|---|
| Safety | Structural - no SQL strings to inject |
| JSON | Native json(), json_path() operators |
| Expressions | coalesce(), concat(), cast() builders |
| CTEs | with_cte() for complex queries |
| Async | Full async/await support |
| Type Validation | ColumnType enum with compile-time checks |
ColumnType Validation
QAIL validates types at build time:
#![allow(unused)]
fn main() {
pub enum ColumnType {
Uuid, Text, Varchar(Option<u16>), Int, BigInt,
Serial, BigSerial, Bool, Float, Decimal(Option<(u8,u8)>),
Jsonb, Timestamp, Timestamptz, Date, Time, Bytea,
}
// Compile-time validation
ColumnType::Uuid.can_be_primary_key() // true
ColumnType::Jsonb.can_be_primary_key() // false - caught at build time
ColumnType::Jsonb.supports_indexing() // false - warned before migration
}
When to Use QAIL
QAIL shines for:
- Complex PostgreSQL queries with JSON, CTEs, aggregates
- Type-safe query building with IDE support
- Production systems where safety is critical
- Projects that need advanced SQL features without string literals
Text Syntax
For CLI, LSP, and WASM playground. Parses to AST internally.
Keywords
| Keyword | Description | Example |
|---|---|---|
get | SELECT query | get users fields * |
set | UPDATE query | set users values ... |
del | DELETE query | del users where ... |
add | INSERT query | add users values ... |
fields | Select columns | fields id, email |
where | Filter conditions | where active = true |
order by | Sort results | order by name desc |
limit | Limit rows | limit 10 |
offset | Skip rows | offset 20 |
left join | Left outer join | left join profiles |
Examples
Simple Select
get users fields *
→ SELECT * FROM users
Filtered Query
get users
fields id, email, name
where active = true
order by created_at desc
limit 50
Join Query
get users
inner join bookings
fields id, email, bookings.total
where created_at >= 2024-01-01
Insert
add users values (email = "alice@example.com", name = "Alice")
Update
set users values (status = "active") where id = 42
PostgreSQL Driver
The qail-pg crate provides a native PostgreSQL driver with AST-native wire protocol encoding.
Features
- AST-Native — Direct AST to wire protocol, no SQL strings
- 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();
}
| Method | Description |
|---|---|
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
}
| Option | Default | Description |
|---|---|---|
idle_timeout | 10 min | Stale connections auto-discarded |
acquire_timeout | 30 sec | Max wait for connection |
connect_timeout | 10 sec | Max time to establish new connection |
Bulk Insert (COPY Protocol)
High-performance bulk insert using PostgreSQL’s COPY protocol:
#![allow(unused)]
fn main() {
use qail_core::ast::Value;
let cmd = Qail::add("users").columns(&["name", "email"]);
let rows = vec![
vec![Value::Text("Alice".into()), Value::Text("a@x.com".into())],
vec![Value::Text("Bob".into()), Value::Text("b@x.com".into())],
];
let count = driver.copy_bulk(&cmd, &rows).await?;
// count = 2
}
Cursor Streaming
Stream large result sets in batches:
#![allow(unused)]
fn main() {
let cmd = Qail::get("logs").select_all();
let batches = driver.stream_cmd(&cmd, 1000).await?;
for batch in batches {
for row in batch {
// Process row
}
}
}
Transactions
#![allow(unused)]
fn main() {
use qail_pg::PgConnection;
let mut conn = PgConnection::connect("localhost", 5432, "user", "db").await?;
conn.begin_transaction().await?;
// ... queries ...
conn.commit().await?;
// Or rollback on error
conn.rollback().await?;
}
⚠️ Raw SQL (Discouraged)
execute_raw exists for legacy compatibility but violates AST-native philosophy.
Use AST-native alternatives:
- Transactions:
conn.begin_transaction(),conn.commit(),conn.rollback() - DDL: Use QAIL schema syntax and migrate command
#![allow(unused)]
fn main() {
// ❌ Avoid
driver.execute_raw("BEGIN").await?;
// ✅ Prefer AST-native
let mut conn = pool.acquire().await?;
conn.begin_transaction().await?;
// ... queries ...
conn.commit().await?;
}
Row Decoding
By Index
#![allow(unused)]
fn main() {
let name = row.get_string(0);
let age = row.get_i32(1);
}
By Column Name (Recommended)
#![allow(unused)]
fn main() {
// Safer - column order changes don't break code
let name = row.get_string_by_name("name");
let age = row.get_i32_by_name("age");
let email = row.get_string_by_name("email");
// Check if NULL
if row.is_null_by_name("deleted_at") { ... }
}
Available get_by_name methods:
get_string_by_name,get_i32_by_name,get_i64_by_nameget_f64_by_name,get_bool_by_nameget_uuid_by_name,get_json_by_nameis_null_by_name,column_index
Supported Types
| Rust Type | PostgreSQL Type |
|---|---|
i16/i32/i64 | INT2/INT4/INT8 |
f32/f64 | FLOAT4/FLOAT8 |
bool | BOOLEAN |
String | TEXT/VARCHAR |
Vec<u8> | BYTEA |
Uuid | UUID |
Timestamp | TIMESTAMPTZ |
Date | DATE |
Time | TIME |
Json | JSONB |
Numeric | NUMERIC/DECIMAL |
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:
| Database | Constructor | Purpose |
|---|---|---|
| PostgreSQL | Qail::get("users") | Query facts |
| Qdrant | Qail::search("products") | Search meaning |
| Redis | Qail::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:
- Schema-Diff (State-Based): Compare standard schema files (good for evolving production DBs)
- File-Based (Sequential): Apply
.qailfiles frommigrations/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
| Hint | Description |
|---|---|
rename table.old -> table.new | Rename column (not drop+add) |
transform expr -> table.col | Data transformation hint |
drop confirm table.col | Explicit drop confirmation |
Named Migrations
Named migrations provide version-controlled migration files with metadata for better tracking.
Creating a Named Migration
qail migrate create add_user_avatars --depends 002_add_users --author orion
Output:
📝 Creating Named Migration
✓ Created: migrations/20251226071129_add_user_avatars.qail
Migration: 20251226071129_add_user_avatars
Depends: 002_add_users
Author: orion
Migration File Format
-- migration: 20251226071129_add_user_avatars
-- depends: 002_add_users
-- author: orion
-- created: 2025-12-26T07:11:29+08:00
+table avatars {
id UUID primary_key
user_id UUID not_null references(users.id)
url TEXT not_null
}
Metadata Fields
| Field | Description |
|---|---|
migration | Unique name (timestamp_description) |
depends | Comma-separated list of dependencies |
author | Author of the migration |
created | ISO 8601 timestamp |
CLI Options
qail migrate create <name>
-d, --depends <migration> # Dependencies (comma-separated)
-a, --author <name> # Author attribution
Dependency Resolution
QAIL validates dependencies before applying migrations:
- Checks all dependencies exist
- Detects circular dependencies
- Applies in topological order
Data-Safe Migrations
QAIL provides enterprise-grade migration safety features that protect your data during schema changes.
Overview
| Feature | Description |
|---|---|
| Impact Analysis | Shows exactly what data will be affected |
| Pre-Migration Backup | Option to backup before destructive changes |
| Record-Level Backup | JSONB-based data backup in database |
| Shadow Database | Blue-green migrations for zero-downtime |
Phase 1: Impact Analysis & Backup Prompt
When running migrations with destructive operations, QAIL analyzes the impact:
$ qail migrate up old.qail:new.qail postgres://...
🚨 Migration Impact Analysis
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DROP COLUMN users.email → 1,234 values at risk
DROP TABLE sessions → 5,678 rows affected
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total: 6,912 records at risk
Choose an option:
[1] Proceed (I have my own backup)
[2] Backup to files (_qail_snapshots/)
[3] Backup to database (with rollback support)
[4] Cancel migration
Options Explained
- [1] Proceed - Continue without QAIL backup (you manage your own)
- [2] File Backup - Export affected data to
_qail_snapshots/directory - [3] Database Backup - Store data in
_qail_data_snapshotstable (enables true rollback) - [4] Cancel - Abort the migration
Phase 2: Record-Level Database Backup
When you choose option [3], QAIL creates a snapshot table:
-- Automatically created
CREATE TABLE _qail_data_snapshots (
id SERIAL PRIMARY KEY,
migration_version VARCHAR(255) NOT NULL,
table_name VARCHAR(255) NOT NULL,
column_name VARCHAR(255),
row_id TEXT NOT NULL,
value_json JSONB NOT NULL,
snapshot_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
What Gets Backed Up
| Operation | Backup Content |
|---|---|
| DROP COLUMN | Column values with row IDs |
| DROP TABLE | Full table as JSONB objects |
| ALTER TYPE | Original values before cast |
True Data Rollback
After migration, you can restore data:
# Schema rollback (adds column back)
qail migrate down new.qail:old.qail postgres://...
# Data rollback (restores values)
# Coming in future release: qail rollback --data
Phase 3: Shadow Database (Blue-Green)
For zero-downtime migrations, use shadow database mode:
# Step 1: Create shadow, apply migrations, sync data
qail migrate shadow old.qail:new.qail postgres://...
🔄 Shadow Migration Mode
━━━━━━━━━━━━━━━━━━━━━━━━━━
[1/4] Creating shadow database: mydb_shadow ✓
[2/4] Applying migration to shadow... ✓
[3/4] Syncing data from primary to shadow...
✓ users (1,234 rows)
✓ orders (5,678 rows)
✓ Synced 2 tables, 6,912 rows
[4/4] Shadow ready for validation
Shadow URL: postgres://...mydb_shadow
Available Commands:
qail migrate promote → Switch traffic to shadow
qail migrate abort → Drop shadow, keep primary
Shadow Workflow
- Create Shadow - New database with new schema
- Apply Migrations - Run DDL on shadow only
- Sync Data - Copy data from primary
- Validate - Test your application against shadow
- Promote or Abort - Make the decision
Promote (Go Live)
$ qail migrate promote postgres://...
🚀 Promoting Shadow to Primary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[1/3] Renaming mydb → mydb_old_20241226 ✓
[2/3] Renaming mydb_shadow → mydb ✓
[3/3] Keeping old database as backup
✓ Shadow promoted successfully!
Old database preserved as: mydb_old_20241226
To clean up: DROP DATABASE mydb_old_20241226
Abort (Rollback)
$ qail migrate abort postgres://...
🛑 Aborting Shadow Migration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Dropping shadow database: mydb_shadow
✓ Shadow database dropped. Primary unchanged.
Comparison with Other Tools
| Feature | QAIL | Prisma | SeaORM | Liquibase |
|---|---|---|---|---|
| Schema Migrations | ✅ | ✅ | ✅ | ✅ |
| Impact Analysis | ✅ | ❌ | ❌ | ❌ |
| Pre-Migration Backup | ✅ | ❌ | ❌ | ❌ |
| Record-Level Backup | ✅ | ❌ | ❌ | ❌ |
| Shadow Database | ✅ | ❌ | ❌ | ❌ |
| True Data Rollback | ✅ | ❌ | ❌ | ❌ |
Best Practices
- Always use database backup for production migrations
- Test in shadow before promoting
- Keep old database for 24-48 hours after promotion
- Use transactions (QAIL does this automatically)
Configuration
No configuration required! Features are enabled by default when destructive operations are detected.
Migration Impact Analyzer
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:
- Scans your codebase for QAIL AST and raw SQL queries
- Detects breaking changes like dropped tables/columns
- Shows exact file:line locations with code snippets
- 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
| Mode | Badge | Detection |
|---|---|---|
| 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:
| Change | Rollback Safe? | Why |
|---|---|---|
ADD COLUMN | ✅ Yes | Can DROP COLUMN |
DROP COLUMN | ❌ No | Data lost permanently |
DROP TABLE | ❌ No | Data lost permanently |
RENAME | ✅ Yes | Can rename back |
ADD INDEX | ✅ Yes | Can drop index |
TRUNCATE | ❌ No | Data lost permanently |
Breaking Change Types
| Change Type | Severity | Description |
|---|---|---|
DROP TABLE | 🔴 Critical | Table referenced in code → runtime errors |
DROP COLUMN | 🔴 Critical | Column queries will fail |
RENAME TABLE | 🟡 Warning | Code needs updating |
RENAME COLUMN | 🟡 Warning | Code needs updating |
TYPE CHANGE | 🟡 Warning | May 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
-
Always run before
migrate upqail migrate analyze old.qail:new.qail --codebase ./src qail migrate up old.qail:new.qail $DATABASE_URL -
Use with
migrate planfor full previewqail migrate plan old.qail:new.qail # See SQL qail migrate analyze old.qail:new.qail # Check codebase qail migrate up old.qail:new.qail $URL # Apply -
Handle irreversible changes carefully
- Backup data before
DROP TABLEorDROP COLUMN - Consider soft-delete (add
deleted_atcolumn) instead of hard delete
- Backup data before
Foreign Key Validation
QAIL provides compile-time validation for foreign key references, ensuring your schema is consistent before migrations run.
Defining Foreign Keys
Use the builder API to define foreign key constraints:
#![allow(unused)]
fn main() {
use qail_core::migrate::{Column, ColumnType, ForeignKey, FkAction};
let user_id = Column::new("user_id", ColumnType::Uuid)
.references("users", "id")
.on_delete(FkAction::Cascade)
.on_update(FkAction::NoAction);
}
FK Actions
| Action | SQL | Description |
|---|---|---|
FkAction::NoAction | NO ACTION | Reject if referenced row exists (default) |
FkAction::Cascade | CASCADE | Delete/update child rows |
FkAction::SetNull | SET NULL | Set FK column to NULL |
FkAction::SetDefault | SET DEFAULT | Set FK column to default value |
FkAction::Restrict | RESTRICT | Same as NO ACTION but checked immediately |
Schema Validation
Call validate() to check all FK references exist:
#![allow(unused)]
fn main() {
let mut schema = Schema::new();
schema.add_table(Table::new("users")
.column(Column::new("id", ColumnType::Uuid).primary_key()));
schema.add_table(Table::new("posts")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(Column::new("user_id", ColumnType::Uuid)
.references("users", "id")));
// Validate all FK references
match schema.validate() {
Ok(()) => println!("Schema is valid"),
Err(errors) => {
for e in errors {
eprintln!("Error: {}", e);
}
}
}
}
Error Messages
If a FK references a non-existent table or column:
FK error: posts.user_id references non-existent table 'users'
FK error: posts.author_id references non-existent column 'users.author_id'
Best Practices
-
Always validate before migrating
#![allow(unused)] fn main() { let schema = parse_qail(&content)?; schema.validate()?; } -
Use Cascade carefully - it can delete more data than expected
-
Prefer SetNull for optional relationships
#![allow(unused)] fn main() { .references("categories", "id") .on_delete(FkAction::SetNull) }
Connection Pooling
Efficient connection reuse with PgPool.
Configuration
#![allow(unused)]
fn main() {
use qail_pg::driver::{PgPool, PoolConfig};
let config = PoolConfig::new("localhost", 5432, "user", "database")
.password("secret")
.max_connections(20)
.min_connections(5);
}
Creating a Pool
#![allow(unused)]
fn main() {
let pool = PgPool::connect(config).await?;
}
Acquiring Connections
#![allow(unused)]
fn main() {
// This waits if all connections are in use
let mut conn = pool.acquire().await?;
// Use the connection
conn.simple_query("SELECT 1").await?;
// Connection automatically returned to pool when dropped
}
Pool Stats
#![allow(unused)]
fn main() {
// Current idle connections
let idle = pool.idle_count().await;
// Maximum configured connections
let max = pool.max_connections();
}
Best Practices
- Create pool once at application startup
- Share via
Arcacross threads/tasks - Don’t hold connections longer than needed
- Set appropriate pool size (CPU cores × 2 is a good start)
#![allow(unused)]
fn main() {
use std::sync::Arc;
let pool = Arc::new(PgPool::connect(config).await?);
// Clone Arc for each task
let pool_clone = pool.clone();
tokio::spawn(async move {
let conn = pool_clone.acquire().await?;
// ...
});
}
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:
- Transactional Write: You write to your PostgreSQL tables.
- Auto-Trigger: A database trigger captures the change and writes a “sync event” to the
_qail_queuetable in the same transaction. - Async Worker: The
qail workerdaemon polls the queue and reliably syncs data to Qdrant.
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
UPSERTevent to queue. - UPDATE: Adds
UPSERTevent (only iftrigger_columnchanged). - DELETE: Adds
DELETEevent 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
| Column | Type | Description |
|---|---|---|
id | SERIAL | Sequence ID for ordering |
ref_table | TEXT | Source table name |
ref_id | TEXT | Primary key of the source row |
operation | TEXT | UPSERT or DELETE |
payload | JSONB | Full row data (snapshot) |
status | TEXT | pending, processing, failed |
retry_count | INT | Number of failed attempts |
error_message | TEXT | Last error message |
Type System
QAIL provides type conversion between Rust and PostgreSQL.
Supported Types
| Rust Type | PostgreSQL Type | Notes |
|---|---|---|
String | TEXT, VARCHAR | UTF-8 |
i32 | INT4 | 32-bit integer |
i64 | INT8, BIGINT | 64-bit integer |
f64 | FLOAT8 | Double precision |
bool | BOOLEAN | |
Uuid | UUID | 128-bit |
Timestamp | TIMESTAMP | Microsecond precision |
Date | DATE | |
Time | TIME | |
Json | JSON, JSONB | |
Decimal | NUMERIC | Arbitrary precision |
Compile-Time Type Safety
QAIL uses the ColumnType enum for compile-time validation in schema definitions:
#![allow(unused)]
fn main() {
use qail_core::migrate::{Column, ColumnType};
// ✅ Compile-time enforced - no typos possible
Column::new("id", ColumnType::Uuid).primary_key()
Column::new("name", ColumnType::Text).not_null()
Column::new("email", ColumnType::Varchar(Some(255))).unique()
// Available types:
// Uuid, Text, Varchar, Int, BigInt, Serial, BigSerial,
// Bool, Float, Decimal, Jsonb, Timestamp, Timestamptz, Date, Time, Bytea
}
Validation at compile time:
primary_key()validates the type can be a PK (UUID, INT, SERIAL)unique()validates the type supports indexing (not JSONB, BYTEA)
Usage
Reading Values
#![allow(unused)]
fn main() {
use qail_pg::types::{Timestamp, Uuid, Json};
for row in rows {
let id: i32 = row.get("id")?;
let uuid: Uuid = row.get("uuid")?;
let created: Timestamp = row.get("created_at")?;
let data: Json = row.get("metadata")?;
}
}
Temporal Types
#![allow(unused)]
fn main() {
use qail_pg::types::{Timestamp, Date, Time};
// Timestamp with microsecond precision
let ts = Timestamp::from_micros(1703520000000000);
// Date only
let date = Date::from_ymd(2024, 1, 15);
// Time only
let time = Time::from_hms(14, 30, 0);
}
JSON
#![allow(unused)]
fn main() {
use qail_pg::types::Json;
let json = Json("{"key": "value"}".to_string());
}
Custom Types
Implement FromPg and ToPg for custom types:
#![allow(unused)]
fn main() {
use qail_pg::types::{FromPg, ToPg, TypeError};
impl FromPg for MyType {
fn from_pg(bytes: &[u8], oid: u32, format: i16) -> Result<Self, TypeError> {
// Decode from wire format
}
}
impl ToPg for MyType {
fn to_pg(&self) -> (Vec<u8>, u32, i16) {
// Encode to wire format
(bytes, oid, format)
}
}
}
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
| Method | Description | Example |
|---|---|---|
typed_eq(col, val) | Type-safe equality | typed_eq(users::active(), true) |
typed_ne(col, val) | Type-safe not-equal | typed_ne(users::status(), "banned") |
typed_gt(col, val) | Type-safe greater-than | typed_gt(users::age(), 18) |
typed_lt(col, val) | Type-safe less-than | typed_lt(users::balance(), 0.0) |
typed_gte(col, val) | Greater-than or equal | typed_gte(users::score(), 100) |
typed_lte(col, val) | Less-than or equal | typed_lte(users::priority(), 5) |
typed_column(col) | Add typed column | typed_column(users::email()) |
SQL to Rust Type Mapping
| SQL Type | Rust Type |
|---|---|
uuid | uuid::Uuid |
text, varchar | String |
integer, int4 | i32 |
bigint, int8 | i64 |
smallint, int2 | i16 |
boolean, bool | bool |
real, float4 | f32 |
double precision, float8 | f64 |
numeric, decimal | f64 |
timestamptz, timestamp | chrono::DateTime<Utc> |
date | chrono::NaiveDate |
jsonb, json | serde_json::Value |
bytea | Vec<u8> |
Reserved Keywords
Rust reserved keywords are automatically escaped:
| Column Name | Generated Function |
|---|---|
type | fn r#type() |
fn | fn r#fn() |
struct | fn 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:
| Check | Level | Description |
|---|---|---|
| Missing primary key | 🔴 ERROR | Every table needs a PK |
| Missing created_at/updated_at | ⚠️ WARNING | Audit trail columns |
_id column without references() | ⚠️ WARNING | FK integrity |
| Uppercase table names | ⚠️ WARNING | Use snake_case |
| SERIAL vs UUID | ℹ️ INFO | Consider UUID for distributed |
| Nullable without default | ℹ️ INFO | Consider default value |
qail 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.qailfile 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
| Flag | Description |
|---|---|
-d, --dialect | Target SQL dialect (pg, mysql) |
-f, --format | Output format (sql, ast, json) |
-v, --verbose | Verbose output |
--version | Show version |
--help | Show help |
API Reference
Full API documentation is generated from source code.
Crates
| Crate | Description | Docs |
|---|---|---|
qail-core | AST, Builder, Parser | docs.rs |
qail-pg | PostgreSQL driver | docs.rs |
qail-wasm | WASM bindings | npm |
Generate Local Docs
cargo doc --no-deps --open
Key Types
qail-core
QailCmd- Query command builderOperator- Comparison operatorsSortOrder- ASC/DESCExpr- Expression AST nodes
qail-pg
PgDriver- Database connectionPgPool- Connection poolPgRow- Result rowPgError- Error types
Source Code
View the source on GitHub: