Raw Queries & Query Building
Drop down to hand-written SQL with prorm.query(), bind values safely with replacements, and inject trusted SQL fragments with prorm.literal() — without giving up prorm's escaping and result shaping.Most of the time you'll reach for the model API (findAll, create, update) or the typed query builder. But when you need a database-specific statement, a hairy analytical query, or a PRAGMA/EXPLAIN, prorm gives you a first-class escape hatch. prorm.query() runs arbitrary SQL, applies your replacements, infers the statement type, and returns results already shaped for that type.[!IMPORTANT] Replacements are inlined, not bound
prorm.query() escapes each replacement for the active dialect and writes
it into the SQL text before the statement is sent; the driver receives no
separate parameter list. The escaping is what keeps it safe, so always pass
values through replacements and never concatenate them yourself. The
WHERE guard does not see raw SQL, and a
query firewall, if you run one, does.
Running a raw query#
prorm.query(sql, options?) executes a SQL string against the connected dialect and returns the results. The shape of the return value depends on the query type (see Result shaping).import { Prorm } from 'ts-prorm-orm';
const prorm = new Prorm({ dialect: 'sqlite', storage: ':memory:' });
await prorm.connect();
// SELECT returns an array of row objects
const rows = await prorm.query('SELECT id, email FROM users');
console.log(rows); // [{ id: 1, email: 'a@example.com' }, ...]
You never build SQL by concatenating user input. Instead you leave placeholders in the string and pass values through the replacements option — prorm escapes them for the active dialect before the query runs.Bind parameters & replacements#
The replacements option accepts either an object (for named :param placeholders) or an array (for positional placeholders). Values are escaped by the dialect: strings are quoted with embedded quotes doubled, null/undefined become NULL, Date objects are formatted, and plain objects are serialised to JSON strings. Booleans and Buffers depend on the dialect:| Value | PostgreSQL | MySQL | SQLite | SQL Server |
|---|
"O'Brien" | 'O''Brien' | 'O''Brien' | 'O''Brien' | 'O''Brien' |
true | true | TRUE | 1 | 1 |
null | NULL | NULL | NULL | NULL |
{ color: 'red' } | '{"color":"red"}' | '{"color":"red"}' | '{"color":"red"}' | '{"color":"red"}' |
Buffer.from('hi') | '{"type":"Buffer","data":[104,105]}' | X'6869' | X'6869' | 0x6869 |
[!WARNING]
On PostgreSQL a Buffer replacement is written as its JSON form, not as
bytea. Insert binary data through the model API, or encode it yourself
(decode(:hex, 'hex') with a hex string).
Named replacements (:param)#
const active = await prorm.query(
'SELECT * FROM users WHERE status = :status AND age >= :minAge',
{
replacements: { status: 'active', minAge: 18 },
},
);
A named parameter must match a key exactly. One that is missing from the object (a typo, or different case) is left in the SQL as written, so the statement reaches the database with :orderId still in it and the driver rejects it — on SQLite with Missing named parameters. Placeholders inside quoted string literals are never replaced, so WHERE note <> 'is it? :maybe' is safe.On SQL Server a named parameter can also be written @name:const top = await prorm.query('SELECT TOP (10) * FROM orders WHERE customer_id = @customerId', {
replacements: { customerId: 42 },
});
// SELECT TOP (10) * FROM orders WHERE customer_id = 42
Positional replacements#
Positional placeholders are filled from the array in order, and their spelling is the dialect's own:const found = await prorm.query('SELECT * FROM users WHERE last_name = $1 AND is_active = $2', {
replacements: ["O'Brien", true],
});
const found = await prorm.query('SELECT * FROM users WHERE last_name = ? AND is_active = ?', {
replacements: ["O'Brien", true],
});
const found = await prorm.query('SELECT * FROM users WHERE last_name = ? AND is_active = ?', {
replacements: ["O'Brien", true],
});
const found = await prorm.query('SELECT * FROM users WHERE last_name = @p1 AND is_active = @p2', {
replacements: ["O'Brien", true],
});
[!CAUTION]
A ? on PostgreSQL or SQL Server is not a placeholder: with an array of
replacements it is left in the SQL untouched, and the database reports a
syntax error. Named :param placeholders work the same way on every
dialect, which makes them the portable choice.
Arrays and IN#
An array value is where the dialects differ most. The same named query, run through each dialect's replaceReplacements():const orders = await prorm.query(
'SELECT id, total FROM orders WHERE customer_id = :customerId AND status IN (:statuses)',
{ replacements: { customerId: 42, statuses: ['paid', 'shipped'] } },
);
The array becomes an array literal, which IN cannot use:Write = ANY(:statuses) instead:The array is serialised to one JSON string, so IN compares with that string
and matches nothing:The array expands into a list:As on MySQL, the array becomes one JSON string:[!WARNING] IN (:list) is only portable through the model API
For a list filter that works everywhere, use
Order.findAll({ where: { status: { [Op.in]: statuses } } }), which binds
each element (see Query safety).
In raw SQL on MySQL or SQL Server, give each element its own placeholder,
as below.
const statuses = ['paid', 'shipped'];
const names = statuses.map((_, i) => `:s${i}`).join(', '); // ':s0, :s1'
const orders = await prorm.query(`SELECT id, total FROM orders WHERE status IN (${names})`, {
replacements: Object.fromEntries(statuses.map((s, i) => [`s${i}`, s])),
});
// SELECT id, total FROM orders WHERE status IN ('paid', 'shipped')
What the database receives#
Pass a logging function to see the SQL after replacement. Run against SQLite:const statements: string[] = [];
const rows = await prorm.query(
'SELECT id, status, total FROM orders WHERE customer_id = :customerId AND status IN (:statuses) ORDER BY id',
{
replacements: { customerId: 42, statuses: ['paid', 'shipped'] },
logging: (sql: string) => statements.push(sql),
},
);
// rows: [{ id: 1, status: 'paid', total: 120 }, { id: 2, status: 'shipped', total: 80 }]
// statements: ["SELECT id, status, total FROM orders WHERE customer_id = 42 AND status IN ('paid', 'shipped') ORDER BY id"]
Choosing the query type#
prorm inspects the leading keyword of your SQL to decide how to process the result. A statement starting with SELECT is treated as a SELECT, INSERT as INSERT (or UPSERT when it contains ON CONFLICT, ON DUPLICATE KEY, or INSERT OR REPLACE), UPDATE as UPDATE, DELETE as DELETE, and PRAGMA/EXPLAIN/anything else as RAW.When the inference is wrong — for example a CTE that begins with WITH but ultimately selects — set type explicitly using the QueryTypes enum (or its string equivalent). A RAW result is the rows too, but only a SELECT-type query can be hydrated with mapToModel:import { QueryTypes } from 'ts-prorm-orm';
const report = await prorm.query(
`WITH recent AS (SELECT * FROM orders WHERE created_at > :since)
SELECT customer_id, COUNT(*) AS n, SUM(total) AS revenue
FROM recent GROUP BY customer_id ORDER BY customer_id`,
{
type: QueryTypes.SELECT,
replacements: { since: '2026-01-01' },
},
);
// [{ customer_id: 7, n: 1, revenue: 15 }, { customer_id: 42, n: 2, revenue: 200 }]
QueryTypes includes SELECT, INSERT, UPDATE, DELETE, BULKDELETE, BULKINSERT, UPSERT, VERSION, SHOWTABLES, DESCRIBE, and RAW.Result shaping#
The return type of prorm.query() follows the (inferred or explicit) query type:| Query type | Return value |
|---|
SELECT, SHOWTABLES, DESCRIBE, VERSION | unknown[] — an array of row objects |
INSERT | [rows, created] — inserted rows plus a boolean |
UPDATE, DELETE, BULKDELETE, UPSERT | number — affected row count |
BULKINSERT | number — inserted row count |
RAW (default) | unknown[] — raw rows |
// UPDATE returns the affected count
const affected = await prorm.query(
'UPDATE users SET status = :status WHERE last_seen < :cutoff',
{ type: QueryTypes.UPDATE, replacements: { status: 'dormant', cutoff: '2025-01-01' } },
);
console.log(`${affected} rows updated`);
// INSERT returns [rows, created]
const [inserted, created] = await prorm.query(
'INSERT INTO tags (name) VALUES (:name)',
{ type: QueryTypes.INSERT, replacements: { name: 'urgent' } },
);
created is true when the driver reports an insert id (rowid or insertId) for the statement; treat it as a hint, and select the row back when you need it.Mapping rows to model instances#
For SELECT-style queries you can hydrate the plain rows into full model instances by passing a model and mapToModel: true. The returned objects are real ModelInstances (with instance methods, not isNewRecord), rather than plain objects.const User = prorm.define('User', { /* ... */ });
const users = await prorm.query('SELECT * FROM users WHERE status = :status', {
replacements: { status: 'active' },
model: User,
mapToModel: true,
});
// users are User instances, not plain row objects
Use raw: true when you explicitly want untouched driver output with no model instantiation.Other query options#
prorm.query() accepts several execution controls alongside type and replacements:await prorm.query('SELECT * FROM big_table', {
logging: (sql, timing) => console.log(`[${timing}ms] ${sql}`), // or `true` / `false`
benchmark: true, // always log execution time
retry: { max: 3, match: ['ECONNREFUSED', 'Timeout'] }, // retry on matching errors
transaction: tx, // run inside an existing transaction
});
logging — true logs to the console, false silences it, or pass a (sql, timing) => void callback.
benchmark — force timing output even when logging is otherwise off.
retry — retry up to max times when the error message matches one of the match patterns.
transaction — bind the query to a Transaction you already opened.
Every executed query also emits a query event (and a slowQuery event when it exceeds the configured slowQueryThreshold), so you can hook centralized instrumentation.prorm.literal() — trusted raw SQL fragments#
prorm.literal(sql) wraps a string so prorm splices it into generated SQL verbatim, instead of quoting it as a value. It returns a Literal object usable inside model options — default values, attributes, where, and update payloads. Because the SQL is injected as-is, only pass literals you construct yourself, never user input.// As a column default: evaluate NOW() in the database
const Event = prorm.define('Event', {
createdAt: { type: DATE, defaultValue: prorm.literal('NOW()') },
});
// In-place update expressions (increment without a read-modify-write)
await View.update(
{ count: prorm.literal('count + 1') },
{ where: { postId: 42 } },
);
// Computed attributes in a SELECT
const rows = await User.findAll({
attributes: [[prorm.literal("first_name || ' ' || last_name"), 'fullName']],
});
// Raw condition in a where clause
const power = await User.findAll({
where: prorm.literal('LENGTH(bio) > 500'),
});
The raw condition is wrapped in parentheses and joined to the rest of the statement; Order.findAll({ where: literal('total > 6') }) on SQLite sends:prorm.literal() and the free function literal() exported from the package
both work as update and create values, and skip attribute validators like other
SQL expressions. On SQLite:await Product.update({ stock: literal('stock - 1') }, { where: { id: 1 } });
[!NOTE]
Before 4.9.2 the free literal() returned a plain { $literal } object that
create() and update() did not recognise, so this stored the text
{"$literal":"stock - 1"} in the column. A JSON value that carries a
$literal key beside other keys is still data.
Expression helpers: fn, col, cast, where#
For structured expressions that stay dialect-aware, prefer the typed helpers over hand-written literals. They exist both as methods on the prorm instance and as standalone functions importable from prorm.// COUNT(id) grouped by role, as a named attribute
const byRole = await User.findAll({
attributes: ['role', [prorm.fn('COUNT', prorm.col('id')), 'count']],
group: ['role'],
});
// col() produces quoted identifiers: col('User', 'name') -> "User"."name"
const upper = prorm.fn('UPPER', prorm.col('User.name'));
// cast() emits a type conversion
const asText = prorm.cast(prorm.col('price'), 'VARCHAR');
// where() pairs an expression with a value or operator object
const in2026 = await User.findAll({
where: prorm.where(prorm.fn('YEAR', prorm.col('createdAt')), 2026),
});
The same helpers are available as free functions, which compose more naturally in shared code:import { fn, col, cast, literal } from 'ts-prorm-orm';
Order.findAll({
attributes: [
'status',
[fn('SUM', col('total')), 'revenue'],
[cast(col('created_at'), 'DATE'), 'day'],
],
});
A revenue-per-status rollup shows the helpers quoted for each engine:import { fn, col } from 'ts-prorm-orm';
const revenue = await Order.findAll({
attributes: ['status', [fn('SUM', col('total')), 'revenue']],
where: { status: 'paid' },
group: ['status'],
});
Lower-level query-builder helpers#
Under the model layer sits a standalone SQL compiler you can drive directly when you want a SQL string without executing it — for building statements to feed into prorm.query(), tests, or migrations. createQuickQuery() returns thin wrappers around the compiler for the common clauses. These live in the internal query-builders module (imported by relative path, not from the package root).import { createQuickQuery } from '../query-builders';
const q = createQuickQuery();
q.select('users', { status: 'active' }); // { sql, values } for a SELECT
q.insert('users', { email: 'a@example.com' }); // compiled INSERT
q.update('users', { status: 'dormant' }, { id: 7 }); // compiled UPDATE
q.delete('users', { id: 7 }); // compiled DELETE
q.where({ age: { $gte: 18 } }); // just the WHERE clause string
q.order([['createdAt', 'DESC']]); // just the ORDER BY clause
q.limitOffset(20, 40); // LIMIT / OFFSET clause
Each of select/insert/update/delete returns a { sql, values } pair produced by the underlying SqlCompiler; where, order, and limitOffset return the corresponding clause fragment. For full control, createQueryBuilder({ parameterized, paramChar }) hands back the raw sqlCompiler instance whose compileSelect, compileInsert, compileUpdate, compileDelete, compileWhere, compileOrder, and compileLimitOffset methods accept richer option objects (joins, CTEs, grouping, having, pivots).Guidelines#
Never interpolate user input into SQL. Use replacements for values and reserve prorm.literal() for SQL you author yourself.
Prefer named :param placeholders. They are spelled the same on every dialect; positional ones are not.
Don't rely on IN (:list) outside SQLite. Use = ANY(:list) on PostgreSQL, one placeholder per element elsewhere, or the model API's Op.in.
Let prorm infer the type, override when it's wrong. Set type for CTEs or statements whose leading keyword doesn't match their behavior.
Prefer typed helpers (fn, col, cast, and the query builder) over raw string SQL where a dialect-aware equivalent exists — you keep portability and escaping.
Reach for prorm.query() for genuinely database-specific features (PRAGMA, vendor extensions, complex analytics) that the model API doesn't cover.
Query safety — the guard model calls get and raw SQL does not Modified at 2026-10-04 00:40:16