🗄️ Sql Toolkit OpenClaw Skill - ClawHub
Do you want your AI agent to automate Sql Toolkit workflows? This free skill from ClawHub helps with web & frontend development tasks without building custom tools from scratch.
What this skill does
Query, design, migrate, and optimize SQL databases. Use when working with SQLite, PostgreSQL, or MySQL — schema design, writing queries, creating migrations, indexing, backup/restore, and debugging slow queries. No ORMs required.
Install
npx clawhub@latest install sql-toolkitFull SKILL.md
Open original| name | description |
|---|---|
| sql-toolkit | Query, design, migrate, and optimize SQL databases. Use when working with SQLite, PostgreSQL, or MySQL — schema design, writing queries, creating migrations, indexing, backup/restore, and debugging slow queries. No ORMs required. |
SQL Toolkit
Work with relational databases directly from the command line. Covers SQLite, PostgreSQL, and MySQL with patterns for schema design, querying, migrations, indexing, and operations.
When to Use
- Creating or modifying database schemas
- Writing complex queries (joins, aggregations, window functions, CTEs)
- Building migration scripts
- Optimizing slow queries with indexes and EXPLAIN
- Backing up and restoring databases
- Quick data exploration with SQLite (zero setup)
SQLite (Zero Setup)
SQLite is included with Python and available on every system. Use it for local data, prototyping, and single-file databases.
Quick Start
# Create/open a database
sqlite3 mydb.sqlite
# Import CSV directly
sqlite3 mydb.sqlite ".mode csv" ".import data.csv mytable" "SELECT COUNT(*) FROM mytable;"
# One-liner queries
sqlite3 mydb.sqlite "SELECT * FROM users WHERE created_at > '2026-01-01' LIMIT 10;"
# Export to CSV
sqlite3 -header -csv mydb.sqlite "SELECT * FROM orders;" > orders.csv
# Interactive mode with headers and columns
sqlite3 -header -column mydb.sqlite
Schema Operations
-- Create table
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Create with foreign key
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total REAL NOT NULL CHECK(total >= 0),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','paid','shipped','cancelled')),
created_at TEXT DEFAULT (datetime('now'))
);
-- Add column
ALTER TABLE users ADD COLUMN phone TEXT;
-- Create index
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- View schema
.schema users
.tables
PostgreSQL
Connection
# Connect
psql -h localhost -U myuser -d mydb
# Connection string
psql "postgresql://user:pass@localhost:5432/mydb?sslmode=require"
# Run single query
psql -h localhost -U myuser -d mydb -c "SELECT NOW();"
# Run SQL file
psql -h localhost -U myuser -d mydb -f migration.sql
# List databases
psql -l
Schema Design Patterns
-- Use UUIDs for distributed-friendly primary keys
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user','admin','moderator')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT users_email_unique UNIQUE(email)
);
-- Auto-update updated_at
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_users_modtime
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Enum type (PostgreSQL-specific)
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'delivered', 'cancelled');
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status order_status NOT NULL DEFAULT 'pending',
total NUMERIC(10,2) NOT NULL CHECK(total >= 0),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Partial index (only index active orders — smaller, faster)
CREATE INDEX idx_orders_active ON orders(user_id, created_at)
WHERE status NOT IN ('delivered', 'cancelled');
-- GIN index for JSONB queries
CREATE INDEX idx_orders_metadata ON orders USING GIN(metadata);
JSONB Queries (PostgreSQL)
-- Store JSON
INSERT INTO orders (user_id, total, metadata)
VALUES ('...', 99.99, '{"source": "web", "coupon": "SAVE10", "items": [{"sku": "A1", "qty": 2}]}');
-- Query JSON fields
SELECT * FROM orders WHERE metadata->>'source' = 'web';
SELECT * FROM orders WHERE metadata->'items' @> '[{"sku": "A1"}]';
SELECT metadata->>'coupon' AS coupon, COUNT(*) FROM orders GROUP BY 1;
-- Update JSON field
UPDATE orders SET metadata = jsonb_set(metadata, '{source}', '"mobile"') WHERE id = '...';
MySQL
Connection
mysql -h localhost -u root -p mydb
mysql -h localhost -u root -p -e "SELECT NOW();" mydb
Key Differences from PostgreSQL
-- Auto-increment (not SERIAL)
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- JSON type (MySQL 5.7+)
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
metadata JSON,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Query JSON
SELECT * FROM orders WHERE JSON_EXTRACT(metadata, '$.source') = 'web';
-- Or shorthand:
SELECT * FROM orders WHERE metadata->>'$.source' = 'web';
Query Patterns
Joins
-- Inner join (only matching rows)
SELECT u.name, o.total, o.status
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE o.created_at > '2026-01-01';
-- Left join (all users, even without orders)
SELECT u.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name;
-- Self-join (find users with same email domain)
SELECT a.name, b.name, SPLIT_PART(a.email, '@', 2) AS domain
FROM users a
JOIN users b ON SPLIT_PART(a.email, '@', 2) = SPLIT_PART(b.email, '@', 2)
WHERE a.id < b.id;
Aggregations
-- Group by with having
SELECT status, COUNT(*) AS cnt, SUM(total) AS revenue
FROM orders
GROUP BY status
HAVING COUNT(*) > 10
ORDER BY revenue DESC;
-- Running total (window function)
SELECT date, revenue,
SUM(revenue) OVER (ORDER BY date) AS cumulative_revenue
FROM daily_sales;
-- Rank within groups
SELECT user_id, total,
RANK() OVER (PARTITION BY user_id ORDER BY total DESC) AS rank
FROM orders;
-- Moving average (last 7 entries)
SELECT date, revenue,
AVG(revenue) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7
FROM daily_sales;
Common Table Expressions (CTEs)
-- Readable multi-step queries
WITH monthly_revenue AS (
SELECT DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY 1
),
growth AS (
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
ROUND((revenue - LAG(revenue) OVER (ORDER BY month)) /
NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100, 1) AS growth_pct
FROM monthly_revenue
)
SELECT * FROM growth ORDER BY month;
-- Recursive CTE (org chart / tree traversal)
WITH RECURSIVE org_tree AS (
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, t.depth + 1
FROM employees e
JOIN org_tree t ON e.manager_id = t.id
)
SELECT REPEAT(' ', depth) || name AS org_chart FROM org_tree ORDER BY depth, name;
Migrations
Manual Migration Script Pattern
#!/bin/bash
# migrate.sh - Run numbered SQL migration files
DB_URL="${1:?Usage: migrate.sh <db-url>}"
MIGRATIONS_DIR="./migrations"
# Create tracking table
psql "$DB_URL" -c "CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
);"
# Run pending migrations in order
for file in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
version=$(basename "$file" .sql)
already=$(psql "$DB_URL" -tAc "SELECT 1 FROM schema_migrations WHERE version='$version';")
if [ "$already" = "1" ]; then
echo "SKIP: $version (already applied)"
continue
fi
echo "APPLY: $version"
psql "$DB_URL" -f "$file" && \
psql "$DB_URL" -c "INSERT INTO schema_migrations (version) VALUES ('$version');" || {
echo "FAILED: $version"
exit 1
}
done
echo "All migrations applied."
Migration File Convention
migrations/
001_create_users.sql
002_create_orders.sql
003_add_users_phone.sql
004_add_orders_metadata_index.sql
Each file:
-- 003_add_users_phone.sql
-- Up
ALTER TABLE users ADD COLUMN phone TEXT;
-- To reverse: ALTER TABLE users DROP COLUMN phone;
Query Optimization
EXPLAIN (PostgreSQL)
-- Show query plan
EXPLAIN SELECT * FROM orders WHERE user_id = '...' AND status = 'paid';
-- Show actual execution times
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = '...' AND status = 'paid';
What to look for:
Seq Scanon large tables → needs an indexNested Loopwith large row counts → considerHash Join(may need morework_mem)Rows Removed by Filterbeing high → index doesn't cover the filter- Actual rows far from estimated → run
ANALYZE tablename;to update statistics
Index Strategy
-- Single column (most common)
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Composite (for queries filtering on both columns)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Column ORDER matters: put equality filters first, range filters last
-- Covering index (includes data columns to avoid table lookup)
CREATE INDEX idx_orders_covering ON orders(user_id, status) INCLUDE (total, created_at);
-- Partial index (smaller, faster — only index what you query)
CREATE INDEX idx_orders_pending ON orders(user_id) WHERE status = 'pending';
-- Check unused indexes
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;
SQLite EXPLAIN
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 5;
-- Look for: SCAN (bad) vs SEARCH USING INDEX (good)
Backup & Restore
PostgreSQL
# Full dump (custom format, compressed)
pg_dump -Fc -h localhost -U myuser mydb > backup.dump
# Restore
pg_restore -h localhost -U myuser -d mydb --clean --if-exists backup.dump
# SQL dump (portable, readable)
pg_dump -h localhost -U myuser mydb > backup.sql
# Dump specific tables
pg_dump -h localhost -U myuser -t users -t orders mydb > partial.sql
# Copy table to CSV
psql -c "\copy (SELECT * FROM users) TO 'users.csv' CSV HEADER"
SQLite
# Backup (just copy the file, but use .backup for consistency)
sqlite3 mydb.sqlite ".backup backup.sqlite"
# Dump to SQL
sqlite3 mydb.sqlite .dump > backup.sql
# Restore from SQL
sqlite3 newdb.sqlite < backup.sql
MySQL
# Dump
mysqldump -h localhost -u root -p mydb > backup.sql
# Restore
mysql -h localhost -u root -p mydb < backup.sql
Tips
- Always use parameterized queries in application code — never concatenate user input into SQL
- Use
TIMESTAMPTZ(notTIMESTAMP) in PostgreSQL for timezone-aware dates - Set
PRAGMA journal_mode=WAL;in SQLite for concurrent read performance - Use
EXPLAINbefore deploying any query that runs on large tables - PostgreSQL:
\d+ tablenameshows columns, indexes, and size.\di+lists all indexes with sizes - For quick data exploration, import any CSV into SQLite:
sqlite3 :memory: ".mode csv" ".import file.csv t" "SELECT ..."