FindOptions object into SQL for the active dialect. This guide coversusers, orders andproducts — and every SQL string on this page is what the dialect's builder"col" vs `col` vs [col]), one engine is shown.prorm.define() send the statement with the bound valuesescape()), so that is thedialect.buildSelectQuery() — returns placeholders plus a separate valuespipe() shows that form.import { Prorm, DataTypes } from 'ts-prorm-orm';
export const prorm = new Prorm({ dialect: 'postgres', host: 'localhost', database: 'shop' });
export const User = prorm.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING(100) },
email: { type: DataTypes.STRING(255) },
age: { type: DataTypes.INTEGER() },
status: { type: DataTypes.STRING(20) },
preferences: { type: DataTypes.JSON() },
}, { tableName: 'users', timestamps: false });
export const Order = prorm.define('Order', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
userId: { type: DataTypes.INTEGER() },
status: { type: DataTypes.STRING(20) },
total: { type: DataTypes.DECIMAL(10, 2) },
createdAt: { type: DataTypes.DATE() },
}, { tableName: 'orders', timestamps: false });
export const Product = prorm.define('Product', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
sku: { type: DataTypes.STRING(32) },
name: { type: DataTypes.STRING(200) },
price: { type: DataTypes.DECIMAL(10, 2) },
stock: { type: DataTypes.INTEGER() },
}, { tableName: 'products', timestamps: false });where, include,order and the rest behave identically whichever one you call.FindOptions produces LIMIT ... OFFSET on PostgreSQL andOFFSET ... FETCH NEXT on MSSQL without your code changing:import { Op } from 'ts-prorm-orm';
const page = await Order.findAll({
where: { status: 'paid', total: { [Op.gte]: 100 } },
order: [['createdAt', 'DESC']],
limit: 20,
offset: 40,
});findAll call does this:transactionContext: true), the pool'spipe() and Q.| Method | Returns | Notes |
|---|---|---|
findAll(options) | Model[] | The workhorse. Everything else is built on it. |
findOne(options) | Model | null | Adds LIMIT 1. |
findByPk(pk, options) | Model | null | Looks up by primary key. |
findOneOrFail(options) | Model | Throws EmptyResultError instead of returning null. |
findByPkOrFail(pk, options) | Model | Same, by primary key. |
findLast(options) | Model | null | Orders by primary key descending, LIMIT 1. |
findAndCountAll(options) | { rows, count } | Rows for the page, count for the whole set. |
findOrCreate(options) | [Model, created] | Finds by where, inserts defaults if missing. |
findOrBuild(options) | [Model, built] | Same, but does not insert. |
count(options) | number | SELECT COUNT(*). |
exists(where) | boolean | A count under the hood. |
min / max / sum / avg | number | Real SQL aggregates. |
findInBatches(options, handler, { batchSize }) | number | Keyset pagination over the whole table. |
findEach(options, handler, { batchSize }) | number | Same, one row at a time. |
LIMIT, so "one row" becomes TOP(1):const ada = await User.findOne({ where: { email: 'ada@example.com' } });
const byKey = await User.findByPk(42);whereAND-ed; operatorsimport { Op } from 'ts-prorm-orm';
await User.findAll({
where: {
name: { [Op.in]: ['a', 'b'] },
age: { [Op.between]: [10, 50] },
email: { [Op.like]: '%@example.com' },
},
});Op.or / Op.and nest:await User.findAll({
where: { [Op.or]: [{ age: { [Op.lt]: 25 } }, { status: 'off' }] },
});await Order.findAll({
where: {
status: { [Op.in]: ['paid', 'shipped'] },
total: { [Op.between]: [50, 500] },
},
});UnsupportedWhereError before$alias.column$ inside where refers to a column on an eagerly-loadedLIMIT is applied:await User.findAll({
include: [{ model: Post, as: 'posts' }],
where: { '$posts.status$': 'published' },
});AssociationError ratherattributesawait User.findAll({ attributes: ['id', 'name'] });
await User.findAll({ attributes: { exclude: ['email'] } });exclude is expanded into an explicit list rather than emitted asSELECT * EXCEPT(...), because most dialects have no such syntax.[expression, alias] form together withprorm.fn() / prorm.col():await User.findAll({
attributes: ['status', [prorm.fn('COUNT', prorm.col('id')), 'total']],
group: ['status'],
});orderawait User.findAll({ order: [['age', 'DESC'], ['name', 'ASC']] });order must be an array. The accepted entry shapes, all verified againstfindAll:| Form | Result |
|---|---|
[['name','DESC'], ['id','ASC']] | ORDER BY "name" DESC, "id" ASC — the form to prefer |
['name DESC'] | ORDER BY name DESC — raw, passed through unquoted |
[asc('a'), desc('b')], random() | see the helpers below |
prorm.fn(...) expressions | rendered as the function call |
| Form | What actually happens |
|---|---|
order: 'name DESC' (a bare string, not in an array) | silently ignored — no ORDER BY is emitted at all and rows come back unordered |
order: ['name', 'DESC'] (a flat pair) | read as two column names, so it fails with no such column: DESC |
order: [{ name: 'DESC' }] (map form) | throws itemArr is not iterable on most dialects; only MSSQL, Snowflake and Oracle accept it |
[!WARNING] A bare string is silently ignored order: 'name DESC'compiles to noORDER BYat all, and nothing throws.
Always wrap in an array, and prefer the nested-array form.
asc() / desc() / random() helpers are exported at the package root:import { asc, desc, random } from 'ts-prorm-orm';
await User.findAll({ order: [desc('name')] }); // ORDER BY "name" DESC
await User.findAll({ order: [asc('a'), desc('b')] }); // ORDER BY "a" ASC, "b" DESC
await User.findAll({ order: random() }); // see belowawait Product.findAll({ order: [desc('price'), asc('name')] });
await Product.findAll({ order: random(), limit: 3 });"a",`a`, [a]). random() resolves to whatever each engine actually uses for| SQL | Dialects |
|---|---|
RANDOM() | SQLite, Turso, PostgreSQL, TimescaleDB, Greenplum, YugabyteDB, CrateDB, CockroachDB, Redshift, Vertica, Exasol, DuckDB, Snowflake, Trino |
RAND() | MySQL, MariaDB, TiDB, SingleStore, Databricks, Firebird, SAP HANA, Db2, Spanner |
rand() | ClickHouse |
NEWID() | SQL Server |
DBMS_RANDOM.VALUE | Oracle |
RAND() is evaluated once perNEWID() is the correctlimit, offset and paginationconst page = 3, perPage = 20;
const { rows, count } = await Order.findAndCountAll({
where: { status: 'paid' },
order: [['id', 'ASC']],
limit: perPage,
offset: (page - 1) * perPage,
});
const pages = Math.ceil(count / perPage);[!TIP]
SQL Server'sOFFSET … FETCHis only legal after anORDER BY, so give every
paged query an explicitorder— which you want anyway, since without one
no engine promises a stable page.
findAndCountAll drops limit/offset from the count query but keepscount describes the full result androws describes the page.OFFSET:await Order.findInBatches({ where: { status: 'paid' } }, async (batch) => {
await ship(batch);
}, { batchSize: 500 });SELECT TOP(500) … ORDER BY [id] ASC on SQL Server), and each later round"id" > <last seen>, so the cost does not grow as you get deeper into thefindEach is the same loop with a per-row handler.[!TIP] OFFSET 100000still reads and discards 100,000 rows. Keyset iteration seeks
straight toid > last, so usefindInBatchesfor exports and backfills and
keepoffsetfor user-facing page numbers.
group and havingconst byStatus = await Order.findAll({
attributes: [
'status',
[prorm.fn('COUNT', prorm.col('id')), 'orders'],
[prorm.fn('SUM', prorm.col('total')), 'revenue'],
],
group: ['status'],
having: { status: { [Op.ne]: 'cancelled' } },
raw: true,
});
// [{ status: 'paid', orders: 42, revenue: 5120.5 }, …]having accepts the same operators as where, including Op.* symbol keys.groupType: 'rollup' | 'cube' | 'grouping' and groupingSets emitROLLUP(...), CUBE(...) and GROUPING SETS (...) on dialects that have themWITH ROLLUP only). The same goes fordistinctOn and sample (TABLESAMPLE, PostgreSQL). A dialect that cannotdistinctawait User.findAll({ attributes: ['status'], distinct: true });const paidOrders = await Order.count({ where: { status: 'paid' } });
const revenue = await Order.sum('total', { where: { status: 'paid' } });
const biggest = await Order.max('total');
const smallest = await Order.min('total');
const average = await Order.avg('total');where, scopes and the paranoid deletedAt IS NULL filter.| Method | SQL |
|---|---|
create(values) | INSERT INTO … VALUES (…) |
bulkCreate(rows) | one INSERT per row (a loop, not a multi-row VALUES list); individualHooks: true routes each row through create() so per-row hooks fire |
update(values, { where }) | UPDATE … SET … WHERE … |
destroy({ where }) | DELETE FROM … WHERE … (or a deletedAt update on a paranoid model) |
truncate() | TRUNCATE / DELETE FROM |
upsert(values) | insert with the dialect's conflict clause |
increment / decrement | arithmetic in SQL |
bulkUpdate(rows, { key }) | one UPDATE … CASE statement for many rows |
update and destroy need a where that selects rows: with no where, orwhere: {}), they throw Missing where option in update /… in destroy before anything is sent. Pass all: true to write the wholeOrder.destroy({ all: true })). Before 4.9.2, updatewhere and destroy({ where: {} }) changed every row. Seeincrement / decrementawait Product.increment('stock', { where: { id: 7 }, by: 5 });
await Product.decrement({ stock: 2 }, { where: { id: 7 } });decrement is increment by the negated amount, which is why it renders as+ -2.bulkUpdateawait User.bulkUpdate([{ id: 1, status: 'x' }, { id: 2, status: 'y' }], { key: 'id' });WHERE does not match are untouched, and the ELSE keeps columnsupsertawait Product.upsert({ id: 1, sku: 'KB-01', name: 'Keyboard', price: 49 });ON CONFLICT … DO UPDATE onON DUPLICATE KEY UPDATE on MySQL/MariaDB, MERGE onMERGE statementdefine() model the upsert is bracketed by a primary-key SELECT beforeSELECT * FROM "products" WHERE "id" = 1 LIMIT 1), which is howfindOrCreateconst [user, created] = await User.findOrCreate({
where: { email: 'ada@example.com' },
defaults: { name: 'Ada', age: 36 },
});[!CAUTION]
The select and the insert are two statements. Two requests racing on the samewherecan both see "missing" and both insert — back thewherecolumns with
a unique index, and run the call in a transaction, so
the loser fails instead of duplicating.
await Order.findAll({
cte: [{ name: 'big_orders', query: 'SELECT * FROM orders WHERE total >= 500' }],
tableName: 'big_orders',
order: [['total', 'DESC']],
});tableName overrides the FROM target so the query reads from the CTE insteadrecursive: true on the CTE entry.const users = await User.findAll({ withCount: ['posts'] });
users[0].postsCount; // numberSELECT * FROM "posts" WHERE "userId" IN (…) — and tallies the rows in memory.count().raw: true skips instance construction and hands back plain objects. Use itconst rows = await User.findAll({ raw: true });
rows[0].save; // undefined — these are plain objectsawait User.findAll({
where: { status: 'active' },
logging: (sql, ms) => console.log(sql, ms),
benchmark: true,
});logging: false silences one query on an otherwise-logging connection. Seeusing: 'analytics' routes one query to another connection registered on theConnectionManager,lock shape compiles — true, a level{ of: Model }, and the { level, nowait, skipLocked } object form.await prorm.transaction(async (t) => {
const [next] = await Product.findAll({
where: { id: 7 },
lock: { level: 'UPDATE', skipLocked: true },
transaction: t,
});
});lock | PostgreSQL | MySQL | Oracle | SQL Server |
|---|---|---|---|---|
true / 'UPDATE' | FOR UPDATE | FOR UPDATE | FOR UPDATE | WITH (UPDLOCK, ROWLOCK) |
'SHARE' | FOR SHARE | LOCK IN SHARE MODE | throws | WITH (HOLDLOCK, ROWLOCK) |
'KEY SHARE' / 'NO KEY UPDATE' | FOR KEY SHARE / FOR NO KEY UPDATE | throws | throws | throws |
{ level: 'UPDATE', nowait: true } | FOR UPDATE NOWAIT | FOR UPDATE NOWAIT | FOR UPDATE NOWAIT | WITH (UPDLOCK, ROWLOCK, NOWAIT) |
{ level: 'UPDATE', skipLocked: true } | FOR UPDATE SKIP LOCKED | FOR UPDATE SKIP LOCKED | FOR UPDATE SKIP LOCKED | WITH (UPDLOCK, ROWLOCK, READPAST) |
{ skipLocked: true } (no level) | FOR UPDATE SKIP LOCKED | FOR UPDATE SKIP LOCKED | FOR UPDATE SKIP LOCKED | WITH (UPDLOCK, ROWLOCK, READPAST) |
{ of: Model } | FOR UPDATE OF "users" | FOR UPDATE OF `users` | FOR UPDATE | WITH (UPDLOCK, ROWLOCK) |
nowait and skipLocked togetherlock silently — the transaction it wouldunion. Forwarded, with the branch values bound correctly:await Active.findAll({ where: { status: 'a' }, unionType: 'UNION ALL',
union: [{ model: Archived, where: { status: 'x' } }] });EXCEPT and INTERSECT work the same way, and findAndCountAll /count() /exists() count over the combined query, so count and rows.length agree:UNION andUNION ALL count correctly without special-casing. The outer limit/offsetorder are excluded from the count — none can change it, and severalORDER BY in a derived table.[!WARNING]
Combiningunionwith a top-levellimitis broken on SQLite — the
dialect emitsLIMITbefore theUNIONkeyword and SQLite rejects it
(LIMIT clause should come after UNION ALL not before). Put the limit on
each branch, or useprorm.query().
transaction. Thread transaction: t through every call that should beOp.*include, nested and filtering includesFindOptions fragmentsprorm.query(), replacements, query types