A type-safe, database-agnostic SQL query builder for TypeScript with built-in connectors for MySQL, PostgreSQL, and SQLite.
npm install queriableInstall the connector for your database:
npm install mysql2 # MySQL
npm install pg # PostgreSQL
npm install better-sqlite3 # SQLiteimport { createDB, SQLiteConnector } from 'queriable';
// 1. Define your schema
interface Schema {
users: { id: number; name: string; email: string; age: number };
orders: { id: number; user_id: number; total: number; status: string };
}
// 2. Create a typed DB with a connector
const db = createDB<Schema>(new SQLiteConnector({ filename: 'app.db' }));
// 3. Full type safety — table names autocomplete, columns validate, results are typed
const users = await db.table('users')
.select('id', 'name')
.where('age', 18, '>=')
.getAll();
users[0].id; // number
users[0].name; // string- Schema & Connectors
- Select
- Insert
- Update
- Delete
- Aggregates
- Find
- Pagination
- Joins
- Where Clauses
- Conditional Clauses
- Ordering, Grouping, Limit & Offset
- Having
- Union
- Increment & Decrement
- Truncate
- Clone
- Transactions
- Raw Queries
- SQL Output
Define your tables as a TypeScript interface:
interface Schema {
users: { id: number; name: string; email: string; age: number };
orders: { id: number; user_id: number; total: number; status: string };
}This gives you:
- Table name autocomplete on
.table() - Column name validation on
.select(),.where(),.orderBy(),.groupBy(), etc. - Type-safe
.insert()and.update()data - Typed query results from
.get()and.getAll()
MySQL
import { createDB, MySQLConnector } from 'queriable';
const db = createDB<Schema>(new MySQLConnector({
host: 'localhost',
port: 3306,
user: 'root',
password: 'secret',
database: 'myapp',
pool: { max: 10 },
}));PostgreSQL
import { createDB, PostgresConnector } from 'queriable';
const db = createDB<Schema>(new PostgresConnector({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'secret',
database: 'myapp',
pool: { max: 10 },
ssl: false,
}));SQLite
import { createDB, SQLiteConnector } from 'queriable';
const db = createDB<Schema>(new SQLiteConnector({
filename: 'app.db', // or ':memory:' for in-memory
}));Without a connector (SQL generation only)
const db = createDB<Schema>();
const sql = db.table('users')
.select('id', 'name')
.where('age', 18, '>=')
.toSQL();
// SELECT id, name FROM users WHERE age >= 18// Get all rows
const users = await db.table('users')
.select('id', 'name', 'age')
.getAll();
// { id: number; name: string; age: number }[]
// Get a single row (LIMIT 1, returns null if not found)
const user = await db.table('users')
.select('id', 'name')
.where('id', 1)
.get();
// { id: number; name: string } | nullDistinct
db.table('users').distinct().select('name').toSQL();
// SELECT DISTINCT name FROM usersselectRaw
db.table('users').selectRaw('SELECT COUNT(*) as total').toSQL();
// SELECT COUNT(*) as total FROM usersExecutes the query and returns { affectedRows, insertId }:
const result = await db.table('users')
.insert({ name: 'John', email: 'john@test.com', age: 30 });
result.insertId; // 1
result.affectedRows; // 1Bulk insert
await db.table('users').insert([
{ name: 'John', email: 'john@test.com', age: 30 },
{ name: 'Jane', email: 'jane@test.com', age: 25 },
]);Executes the query and returns { affectedRows }:
const result = await db.table('users')
.where('id', 1)
.update({ name: 'John', age: 31 });
result.affectedRows; // 1Executes the query and returns { affectedRows }:
const result = await db.table('users')
.where('id', 1)
.delete();
result.affectedRows; // 1All aggregate methods execute immediately and return a number:
await db.table('users').count('id'); // 5
await db.table('users').sum('age'); // 150
await db.table('users').avg('age'); // 30
await db.table('users').min('age'); // 18
await db.table('users').max('age'); // 65Aggregates respect WHERE clauses:
await db.table('users').where('status', 'active').count('id'); // 3Shorthand for .where(column, value).get():
const user = await db.table('users')
.select('id', 'name')
.find('id', 1);
// { id: number; name: string } | nullReturns paginated results with metadata:
const page = await db.table('users')
.select('id', 'name')
.orderBy('name')
.paginate(1, 10);
page.data; // { id: number; name: string }[]
page.total; // 50 (total matching rows)
page.page; // 1
page.perPage; // 10
page.lastPage; // 5db.table('users')
.select('id', 'name')
.leftJoin('orders', 'users.id', 'orders.user_id')
.toSQL();
// SELECT id, name FROM users LEFT JOIN orders ON users.id = orders.user_idWith alias
.leftJoin('orders', 'users.id', 'orders.user_id', 'o')
// LEFT JOIN orders AS o ON users.id = orders.user_idOther join types
.rightJoin('orders', 'users.id', 'orders.user_id')
.innerJoin('orders', 'users.id', 'orders.user_id')
.crossJoin('roles')
.joinRaw('LEFT JOIN orders o ON users.id = o.user_id')Advanced join with conditions
db.table('users')
.join(qb => {
qb.leftJoin('orders')
.on('users.id', 'orders.user_id')
.and('orders.status', 'active')
.or('orders.priority', 'high');
})
.toSQL();db.table('users').where('age', 18, '>=').toSQL();
// WHERE age >= 18
db.table('users').where('age', 18, '>=').where('status', 'active').toSQL();
// WHERE age >= 18 AND status = 'active'
db.table('users').where('status', 'active').orWhere('status', 'pending').toSQL();
// WHERE status = 'active' OR status = 'pending'Compare two columns without value escaping:
db.table('users').whereColumn('users.id', 'orders.user_id').toSQL();
// WHERE users.id = orders.user_iddb.table('users').whereIn('id', [1, 2, 3]).toSQL();
// WHERE id IN (1, 2, 3)
db.table('users').whereNotIn('status', ['banned', 'suspended']).toSQL();
// WHERE status NOT IN ('banned', 'suspended')db.table('users').whereBetween('age', 18, 65).toSQL();
// WHERE age BETWEEN 18 AND 65db.table('users').whereLike('name', 'john').toSQL();
// WHERE name LIKE '%john%'
db.table('users').whereLike('name', 'john%').toSQL();
// WHERE name LIKE 'john%'db.table('users').whereIsNull('deleted_at').toSQL();
// WHERE deleted_at IS NULLdb.table('users')
.select('id', 'name')
.whereExists((qb) => {
qb.table('orders').select('id').whereRaw('WHERE orders.user_id = users.id');
})
.toSQL();
// WHERE EXISTS (SELECT id FROM orders WHERE orders.user_id = users.id)db.table('users')
.whereIn('id', (qb) => {
qb.table('orders').select('user_id').where('total', 100, '>');
})
.toSQL();
// WHERE id IN (SELECT user_id FROM orders WHERE total > 100)db.table('users')
.where('age', 18, '>=')
.orWhere((qb) => {
qb.where('name', 'Admin').where('email', 'admin@test.com');
})
.toSQL();
// WHERE age >= 18 OR (name = 'Admin' AND email = 'admin@test.com')db.table('users').whereRaw('WHERE created_at > %s', '2024-01-01').toSQL();
// WHERE created_at > '2024-01-01'Add clauses only when a condition is truthy:
const filterByAge = true;
const minAge = 18;
const users = await db.table('users')
.select('id', 'name')
.when(filterByAge, (qb) => qb.where('age', minAge, '>='))
.getAll();db.table('users')
.select('id', 'name')
.orderBy('name')
.orderBy('age', 'DESC')
.limit(10)
.offset(20)
.toSQL();
// ORDER BY name ASC, age DESC LIMIT 10 OFFSET 20orderByRaw
db.table('users').orderByRaw('RANDOM()').toSQL();
// ORDER BY RANDOM()groupBy / groupByRaw
db.table('orders').select('status').groupBy('status').toSQL();
// GROUP BY status
db.table('orders').groupByRaw('YEAR(created_at)').toSQL();
// GROUP BY YEAR(created_at)Methods: having, orHaving, havingCount, havingSum, havingAvg, havingMin, havingMax (+ or variants), havingRaw
db.table('orders')
.select('user_id')
.groupBy('user_id')
.havingCount('id', '>', 5)
.toSQL();
// HAVING COUNT(id) > 5
db.table('orders')
.select('user_id')
.groupBy('user_id')
.havingSum('total', '>', 1000)
.orHavingAvg('total', '>', 200)
.toSQL();
// HAVING SUM(total) > 1000 OR AVG(total) > 200const archived = db.table('orders').select('id', 'total');
db.table('orders')
.select('id', 'total')
.union(archived)
.toSQL();
// SELECT id, total FROM orders UNION SELECT id, total FROM ordersUpdate a numeric column by a given amount:
await db.table('users').where('id', 1).increment('age'); // age = age + 1
await db.table('users').where('id', 1).increment('age', 5); // age = age + 5
await db.table('users').where('id', 1).decrement('age', 3); // age = age - 3await db.table('users').truncate();
// TRUNCATE TABLE usersCreate an independent copy of a query builder to build variants:
const base = db.table('users').select('id', 'name');
const active = base.clone().where('status', 'active');
const admins = base.clone().where('role', 'admin');
// base, active, and admins are independent queriesUse the callback pattern for automatic commit/rollback:
await db.transaction(async (trx) => {
await trx.execute(db.table('users').insert({ name: 'Alice', email: 'alice@test.com' }));
await trx.execute(db.table('orders').insert({ user_id: 1, total: 99, status: 'pending' }));
// auto-commits on success
});
// auto-rollbacks if an error is thrownYou can also use raw SQL inside transactions:
await db.transaction(async (trx) => {
await trx.raw("INSERT INTO users (name, email) VALUES ('Alice', 'alice@test.com')");
});Execute arbitrary SQL:
const result = await db.raw<{ cnt: number }>('SELECT COUNT(*) as cnt FROM users');
result.rows[0].cnt; // numberGenerate the SQL string without executing:
const sql = db.table('users')
.select('id', 'name')
.where('age', 18, '>=')
.toSQL();
// SELECT id, name FROM users WHERE age >= 18await db.close();