model · Options: typessku on products. Every SQL string on this page was captured from the ORM. Adefine() model sends its statements with the values already escaped in, soimport { Prorm, DataTypes } from 'ts-prorm-orm';
export const prorm = new Prorm({ dialect: 'postgres', host: 'localhost', database: 'shop' });
export const Product = prorm.define('Product', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
sku: { type: DataTypes.STRING(32), unique: true, validate: { len: [3, 32] } },
name: { type: DataTypes.STRING(200) },
price: { type: DataTypes.DECIMAL(10, 2) },
stock: { type: DataTypes.INTEGER() },
}, { tableName: 'products', 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) },
}, { tableName: 'orders', timestamps: false });| Method | Statements sent | Returns |
|---|---|---|
bulkCreate(records, options) | one INSERT per record | the records as plain objects |
update(values, { where }) | one UPDATE for every matching row | [affectedCount, []] |
destroy({ where }) | one DELETE (or a soft-delete UPDATE) | the affected count |
upsert(values, options) | a SELECT probe, then one native insert-or-update | [values, created] |
bulkCreate is row-oriented — it iterates the array and issues one statement per record, so it is the tool for writing distinct rows.update and destroy are set-oriented — a single SQL statement changes every matching row, so they are the tool for applying the same change to many rows.bulkUpdate / bulkDelete method; those operations areupdate and destroy with a where clause. There is no working bulkINSERT:const created = await Product.bulkCreate([
{ sku: 'HD-01', name: 'Headset', price: 89, stock: 5 },
{ sku: 'CB-01', name: 'USB-C cable', price: 9.5, stock: 120 },
]);`products`, [products]).isNewRecord: false. On SQLite each also gets the generated id:// [{ sku: 'HD-01', name: 'Headset', price: 89, stock: 5, id: 3, isNewRecord: false }, …]id is absent. Use create(), or read the rows back bysku, when you need the ids.[!WARNING]
The default path does not validate. A record thatcreate()would reject
is inserted as-is:await Product.create({ sku: 'x', name: 'Bad', price: 1, stock: 1 }); // ValidationError: Validation len on sku failed await Product.bulkCreate([{ sku: 'x', name: 'Bad', price: 1, stock: 1 }]); // INSERT INTO "products" (…) VALUES ('x', 'Bad', 1, 1) -- no errorPass validate: trueto check every record before anything is written: one
invalid record throws aValidationErrornaming it (row 1: Validation len on sku failed) and noINSERTis sent.individualHooks: truealso
validates, but record by record, so it stops at the first invalid one after
the earlier ones were written.
await Product.bulkCreate(feed, { validate: true });bulkCreate on a model built with define() or addModel() reads only a few ofBulkCreateOptions:| Option | Honoured | Effect |
|---|---|---|
transaction | yes | Runs every INSERT in that transaction. |
individualHooks | yes | Routes each record through create() (validation, beforeCreate / afterCreate). |
logging | yes | Per-call logger. |
validate | yes | validate: true checks every record before the first INSERT. Before 4.9.2 it was ignored. |
hooks: false | no | beforeBulkCreate / afterBulkCreate still run. |
fields, returning, raw | no | Every supplied attribute is written; the result is always plain objects. |
upsert, conflictFields, updateOnDuplicate | no | Plain INSERTs are sent. See Bulk upsert. |
update applies one SET clause to every row matching where in a single[affectedCount, instances]; the instancesimport { Op } from 'ts-prorm-orm';
const [cancelled] = await Order.update(
{ status: 'cancelled' },
{ where: { status: 'pending', total: { [Op.lt]: 20 } } },
);[!IMPORTANT] updateneeds awherethat selects rows. With nowhere, or an empty one
(where: {}, for example a filter built from input that came back empty),
it throws before anything is sent:await Order.update({ status: 'archived' }, { where: {} }); // Error: Missing where option in update: update() without conditions would // affect every row. Pass a where that selects the rows, or { all: true } to // update the whole table. await Order.update({ status: 'archived' }, { all: true }); // UPDATE "orders" SET "status" = 'archived'This is new in 4.9.2: earlier releases sent the UPDATEwith noWHERE
and changed every row. Addall: trueto calls that mean it.
limit caps how many rows change. Each engine spells it differently:await Order.update({ status: 'archived' }, { where: { status: 'paid' }, limit: 100 });returning: true adds RETURNING * on PostgreSQL and OUTPUT INSERTED.* on[affectedCount, []]. Re-query when you need the updated rows.| Option | Honoured | Effect |
|---|---|---|
where | yes | The rows to change. Required, and must not be empty. |
all | yes | all: true updates the whole table on purpose. |
limit | yes | Cap the rows changed (see the tabs above). |
transaction | yes | Run in that transaction. |
returning | partly | Adds RETURNING / OUTPUT; rows are not returned. |
hooks: false | yes | No update hook runs. |
individualHooks | no | The update hooks run once for the statement, not per row (see Hooks). |
validate | yes | The values written are validated (SQL expressions such as literal() are skipped); validate: false skips it. |
silent | yes | silent: true leaves updatedAt alone. |
updatedAt is set in the same statement unless yousilent: true.destroy deletes every row matching where and returns the affected row count.where is required, and an empty one (where: {}) is refused tooMissing where option in destroy); before 4.9.2, where: {} deleted everyall: true to empty the table on purpose:Order.destroy({ all: true }).const removed = await Order.destroy({ where: { status: 'cancelled' }, limit: 500 });[!TIP]
Alimitturns one huge delete into a loop of small ones, so each statement
holds its locks briefly and replication does not stall behind it:while ((await Order.destroy({ where: { status: 'cancelled' }, limit: 500 })) > 0) { // repeat until nothing is left }
destroy sets deletedAt (andupdatedAt) instead of removing rows. Pass force: true to hard-delete:await Order.destroy({ where: { status: 'cancelled' } }); // soft delete
await Order.destroy({ where: { status: 'cancelled' }, force: true }); // real DELETE| Option | Honoured | Effect |
|---|---|---|
where | yes | Required, and must not be empty. |
all | yes | all: true deletes every row on purpose. |
limit | yes | Cap the rows deleted. |
force | yes | Hard-delete on paranoid models. |
transaction | yes | Run in that transaction. |
hooks: false | partly | Skips beforeDestroy; the bulk destroy hooks still run. |
individualHooks | no | Rows are not loaded; no per-instance hooks. |
upsert inserts a row or updates it if it collides with an existing one, using[values, created], wherecreated reports whether a new row was inserted. It first runs a SELECT on theconst [row, created] = await Product.upsert(
{ sku: 'MS-01', name: 'Mouse', price: 17.99, stock: 40 },
{ conflictFields: ['sku'], updateOnDuplicate: ['price', 'stock'] },
);updateOnDuplicate is omitted, every supplied column except the conflictconflictFields, the target is inferred in this order:unique (here sku, soProduct.upsert({ sku: 'MS-01', … }) targets "sku" on its own);ON DUPLICATE KEY UPDATE has no target at all: it fires on any unique[!WARNING]
The conflict columns must be backed by a real unique constraint or primary
key in the database. If they are not, PostgreSQL and SQLite reject the
statement (SQLite:ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint). Declaringunique: trueon the attribute helps only if the
table was created with it.
updateWhere updates an existing row only when it matches a condition (anWHERE to ON CONFLICT … DO UPDATE, SQL ServerMERGE's WHEN MATCHED, and MySQL/MariaDB write eachcol = IF(condition, VALUES(col), col). On MySQL the conditionupdateWhere before anything is sent.createdAt (if absent) and updatedAt are set automatically.bulkCreate accepts upsert: true, conflictFields and updateOnDuplicate inINSERTs. On a table with a uniqueupsert in oneconst feed = [
{ sku: 'KB-01', name: 'Keyboard', price: 44.5, stock: 12 },
{ sku: 'WC-01', name: 'Webcam', price: 39, stock: 3 },
];
await prorm.transaction(async (t) => {
for (const row of feed) {
await Product.upsert(row, {
conflictFields: ['sku'],
updateOnDuplicate: ['price', 'stock'],
transaction: t,
});
}
});sku get the new price and stock; the rest aredefine() model:| Call | Hooks, in order | First argument |
|---|---|---|
Model.create() | afterCreate, afterSave | the instance |
Model.bulkCreate() | beforeBulkCreate, afterBulkCreate | the records array |
bulkCreate(…, { individualHooks: true }) | beforeBulkCreate, beforeCreate / afterCreate per record, afterBulkCreate | per record: the instance |
instance.save() / instance.update() | beforeUpdate, beforeBulkUpdate, afterBulkUpdate, afterUpdate, afterSave | bulk hooks: the options (where: { id }) |
Model.update(values, { where }) | beforeUpdate, beforeBulkUpdate, afterUpdate, afterBulkUpdate | afterUpdate: the values; bulk hooks: the options |
instance.destroy() | beforeDestroy, beforeBulkDestroy, afterBulkDestroy, afterDestroy | afterDestroy: the instance |
Model.destroy({ where }) | beforeDestroy, beforeBulkDestroy, afterDestroy, afterBulkDestroy | the options |
[!IMPORTANT]
AnafterUpdate(instance)hook that readsinstance.idgetsundefinedafter
a set-basedModel.update(), because it receives the values you set, not a
row. Code that must see every write, such as
cache invalidation, belongs in the bulk hooks,
which receive thewherefor both instance and set-based calls.
update, not asave() calls.const BATCH = 500;
for (let i = 0; i < rows.length; i += BATCH) {
const chunk = rows.slice(i, i + BATCH);
await prorm.transaction(async (t) => {
await Product.bulkCreate(chunk, { transaction: t });
});
}individualHooks: true unless you need per-row validation or hooks: itcreate(). Omit returning on update, since[!TIP]
For a feed of tens of thousands of rows, aCOPY(PostgreSQL) orLOAD DATA(MySQL) through raw queries into a staging
table, followed by one set-basedINSERT … SELECT, beats any per-row path.