Database Query Builder
Build database queries with plain JavaScript objects. For the full list of options and operators, see the Query Builder API.
Philosophy: Pure Object Queries
A query is a plain object describing the table, the columns and the conditions. executeQuery(db, query) validates it, turns it into SQL with bound parameters and runs it:
import { createDatabaseManager, executeQuery } from '@coherent.js/database';
const db = createDatabaseManager({ type: 'sqlite', database: ':memory:' });
await db.connect();
const { rows } = await executeQuery(db, {
table: 'users',
select: ['id', 'name', 'email'],
where: { active: true, role: 'admin' },
orderBy: { created_at: 'DESC' },
limit: 10
});
// SELECT id, name, email FROM users WHERE active = ? AND role = ? ORDER BY created_at DESC LIMIT 10Values are always sent as parameters. Everything else that ends up in the SQL text — table and column names, aliases, join conditions, sort directions, limit / offset, operators — is validated, and anything that does not match throws before the query reaches the database. createQuery(config) simply returns a copy of the object, so queries can be built ahead of time and executed later.
SELECT Queries
// All columns
await executeQuery(db, { table: 'users' });
// Specific columns, aliases and aggregates
await executeQuery(db, { table: 'users', select: ['id', 'full_name AS name', 'email'] });
await executeQuery(db, { table: 'users', select: ['COUNT(*) AS total'], where: { active: true } });
// An object maps aliases to columns
await executeQuery(db, { table: 'orders', select: { orderCount: 'COUNT(*)', revenue: 'SUM(total)' } });Select entries may be a column (name or table.name), *, table.*, or COUNT / SUM / AVG / MIN / MAX of a column, each optionally followed by AS alias. Other SQL expressions (DATE(created_at), GROUP BY, HAVING, subqueries) are not part of the builder: use db.query(sql, params) for them.
WHERE Conditions
// Equality (null matches NULL)
where: { active: true, role: 'admin', deleted_at: null }
// Comparison operators
where: {
price: { '>': 100 },
stock: { '>=': 10 },
category: { '!=': 'deprecated' },
rating: { '<': 3 }
}
// Lists, ranges and patterns
where: {
role: { in: ['admin', 'moderator'] },
status: { 'not in': ['banned', 'suspended'] },
age: { between: [18, 65] },
name: { like: 'John%' },
email: { ilike: '%@example.com' } // PostgreSQL
}
// NULL checks
where: {
deleted_at: null, // IS NULL
verified_at: { '!=': null } // IS NOT NULL
}Supported operators: =, !=, <>, >, >=, <, <=, like, not like, ilike, not ilike, in, not in, between, not between (any case). An empty in list matches nothing.
Logical Conditions
where: {
active: true, // AND active = ?
$or: [
{ role: 'admin' },
{ role: 'user', premium: true }
]
}
where: {
category: 'electronics',
$or: [
{ $and: [{ brand: 'Apple' }, { price: { '>': 500 } }] },
{ $and: [{ brand: 'Samsung' }, { rating: { '>=': 4.5 } }] }
],
$not: { discontinued: true }
}What throws
- an unknown operator (
$gt,$in,$ne...) or logical key; - an
undefinedvalue (remove the key, or passnullto match NULL); - an array used as a value (use
{ in: [...] }).
A plain object used as a value is read as an operator object, so never pass unvalidated request data as a WHERE value: where: { id: req.body.id } lets a client send { "id": { ">": 0 } }.
INSERT
// Single row
const { insertId } = await executeQuery(db, {
table: 'users',
insert: { name: 'John Doe', email: 'john@example.com', created_at: new Date() }
});
// Several rows (every row must have the same columns)
await executeQuery(db, {
table: 'users',
insert: [
{ name: 'John Doe', email: 'john@example.com' },
{ name: 'Jane Smith', email: 'jane@example.com' }
]
});
// RETURNING (PostgreSQL, and SQLite 3.35+)
const { rows } = await executeQuery(db, {
table: 'users',
insert: { name: 'John', email: 'john@example.com' },
returning: ['id', 'created_at']
});Columns whose value is undefined are left out.
UPDATE and DELETE
await executeQuery(db, {
table: 'users',
update: { name: 'John Updated', updated_at: new Date() },
where: { id: 123 }
});
await executeQuery(db, {
table: 'users',
delete: true,
where: { active: false, last_login: { '<': '2023-01-01' } }
});An UPDATE or DELETE without a where throws; pass allowFullTable: true to affect every row on purpose. The result carries affectedRows (and rowCount).
JOINs
await executeQuery(db, {
table: 'users',
select: ['users.id', 'users.name', 'profiles.bio'],
joins: [
{ type: 'inner', table: 'profiles', condition: 'users.id = profiles.user_id' },
{ type: 'left', table: 'orders', alias: 'o', condition: 'users.id = o.user_id' }
]
});Join types: inner, left, right, full, cross (and left outer...). A condition is one or more column comparisons joined with AND; values are not allowed in it — put them in where.
Sorting and Pagination
orderBy: { created_at: 'DESC' }
orderBy: [{ created_at: 'DESC' }, { title: 'ASC' }]
orderBy: ['title', 'created_at DESC']
// Page 6 of 20 rows
await executeQuery(db, { table: 'users', orderBy: { id: 'ASC' }, limit: 20, offset: 100 });Directions must be ASC or DESC. limit and offset must be non-negative integers — convert query-string values first (Number.parseInt(req.query.limit, 10)).
Building Queries Dynamically
const SORTABLE = new Set(['name', 'created_at']);
function buildUserQuery(filters = {}) {
const where = {};
if (typeof filters.role === 'string') where.role = filters.role;
if (typeof filters.active === 'boolean') where.active = filters.active;
if (typeof filters.search === 'string' && filters.search) {
where.$or = [
{ name: { like: `%${filters.search}%` } },
{ email: { like: `%${filters.search}%` } }
];
}
return {
table: 'users',
select: ['id', 'name', 'email'],
...(Object.keys(where).length > 0 && { where }),
...(SORTABLE.has(filters.sortBy) && {
orderBy: { [filters.sortBy]: filters.sortDirection === 'DESC' ? 'DESC' : 'ASC' }
})
};
}
const { rows } = await executeQuery(db, buildUserQuery({ role: 'admin', search: 'john', sortBy: 'created_at' }));Check the type of every value that comes from a request, and choose identifiers (sort columns...) from an allowlist.
Transactions
executeQuery() accepts anything with a query(sql, params) method, including a transaction:
const userId = await db.transaction(async (tx) => {
const { insertId } = await executeQuery(tx, {
table: 'users',
insert: { name: 'John', email: 'john@example.com' }
});
await executeQuery(tx, { table: 'profiles', insert: { user_id: insertId, bio: 'Hello world' } });
return insertId;
});Error Handling
Validation errors are thrown before anything is sent to the database; driver errors are rethrown as Query failed: <driver message>:
try {
const { rows } = await executeQuery(db, { table: 'users', where: { id: 123 } });
} catch (error) {
console.error(error.message);
}Query Debugging
debug: true logs every statement with its parameters:
const db = createDatabaseManager({ type: 'sqlite', database: 'app.db', debug: true });MongoDB
The object query builder generates SQL. With type: 'mongodb', use the driver's collection API through db.collection(name) (see the database guide).
Next Steps
- Database guide - Connections, transactions, migrations and models
- Query Builder API - Every option and operator