sync() creates themconst User = prorm.define(
'User',
{
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
orgId: { type: DataTypes.INTEGER(), references: { model: 'orgs', key: 'id', onDelete: 'CASCADE', onUpdate: 'RESTRICT' } },
email: { type: DataTypes.STRING(100) },
name: { type: DataTypes.STRING(50) },
status: { type: DataTypes.STRING(20) },
age: { type: DataTypes.INTEGER() },
},
{
tableName: 'users',
indexes: [
{ fields: ['name'] },
{ name: 'idx_status_age', fields: ['status', 'age'] },
{ name: 'uq_name', fields: ['name'], unique: true },
],
constraints: [
{ type: ConstraintType.Check, name: 'ck_age', check: { age: { gte: 0 } } },
{ type: ConstraintType.Unique, name: 'uq_org_email', fields: ['orgId', 'email'] },
],
},
);sync() emits the same structure on every SQL dialect, spelled the way eachSERIAL against AUTOINCREMENT,TIMESTAMP against DATETIME, and IF NOT EXISTS on SQLite's indexes but notCREATE TABLE; indexes follow as separateidx_<table>_<first field>.CHECK predicate is a where object — the same syntax a query takes,CREATE TABLE carries no parameter list. Comparisons againstimport { ConstraintType, col, fn, literalExpr } from 'ts-prorm-orm';
constraints: [
{ type: ConstraintType.Check, name: 'ck_dates', check: { ends_at: { gt: col('starts_at') } } },
{ type: ConstraintType.Check, name: 'ck_status', check: { status: { in: ['draft', 'live'] } } },
{ type: ConstraintType.Check, name: 'ck_seen', check: { seen_at: { lte: fn('NOW') } } },
]ConstraintType and the rest| Option | Effect |
|---|---|
fields | Column list. Composite indexes list several. |
name | Index name; defaults to idx_<table>_<field>. |
unique | CREATE UNIQUE INDEX. |
expression | Index an expression instead of columns: ((LOWER(email))). |
using | Index method — GIN, GIST, BTREE, HASH (PostgreSQL/MySQL). |
include | Covering-index payload columns (PostgreSQL 11+, MySQL). |
type | Dialect index type keyword. |
parser | MySQL/MariaDB full-text parser. |
tablespace, storageParameters | PostgreSQL storage placement and knobs. |
nullsNotDistinct | NULLS NOT DISTINCT on a unique index (PostgreSQL 15+). |
ifNotExists | Suppress the "already exists" error. |
where | Partial index predicate — see the caveat below. |
indexes: [{ name: 'idx_active', fields: ['status'], where: { status: 'active' } }]UnsupportedSchemaObjectError: Partial (filtered) index is not supported on mysql. mysql has
no WHERE clause on CREATE INDEX - index the whole table, or index a generated/expression column
that encodes the predicate.CREATE INDEX is executed with no parameter list, the predicate's valuesescape()/escapeId(), so a value containing a quote is doubled'O''Brien') and cannot break out of the literal.{ col: value }, { col: [...] } (→ IN),Op.eq/ne/gt/gte/lt/lte/in/notIn/between/notBetween/like/notLike/is/not/isNull/isNotNull/col, and nested Op.and/Op.or/Op.not. Anything else throws, naming the operator. A where given as a plain| Dialect | Partial index |
|---|---|
| SQLite, Turso, PostgreSQL, CockroachDB, Greenplum, TimescaleDB, YugabyteDB | supported |
| SQL Server | supported (a filtered index) |
| MariaDB, Oracle, Db2, SAP HANA, ClickHouse, MySQL, DuckDB, Vertica, Firebird, Exasol | throws UnsupportedSchemaObjectError |
| Spanner | warns and ignores the predicate |
| Snowflake, Redshift, Trino, CrateDB | no indexes at all |
WHERE clause the engine hasPartial (filtered) index is not supported on mariadb. mariadb has no WHERE
clause on CREATE INDEX - index the whole table, or index a generated/expression
column that encodes the predicate.createPartialIndex(table, name, fields, predicate) still takes the predicate as| Option | Effect |
|---|---|
type | 'PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY', 'CHECK'. |
name / constraintName | Constraint name. |
fields | Columns the constraint covers. |
check | Predicate for a CHECK, as a literal SQL string. |
references | { model, field, onDelete, onUpdate, match, deferrable } for a FOREIGN KEY. |
deferrable | INITIALLY DEFERRED / INITIALLY IMMEDIATE / NOT DEFERRABLE (PostgreSQL). |
// 1. attribute-level grouping — columns sharing a uniqueKey form one constraint
const byAttribute = {
orgId: { type: DataTypes.INTEGER(), uniqueKey: 'uq_org_email' },
email: { type: DataTypes.STRING(100), uniqueKey: 'uq_org_email' },
};
// 2. model-level
const byModel = { uniqueKeys: [{ name: 'uq_org_email', fields: ['orgId', 'email'] }] };
// 3. the constraints option
const byConstraint = {
constraints: [{ type: 'UNIQUE', name: 'uq_org_email', fields: ['orgId', 'email'] }],
};UniqueKeyOptions.unique: true produces a unique index instead, which is whatfield / underscored, falling back to theCREATE TABLE actuallysync({ alter: true })used to destroy these. SQLite implementschangeColumnby rebuilding the table, and the rebuild dropped theUNIQUE
clause — so one alter silently un-enforced a key the model still declared.
Declared keys are now re-applied as unique indexes after an alter (idempotent
across re-syncs). Keys the model no longer declares are not dropped, and
the re-apply fails loudly if existing rows already violate a newly declared
key.
const attributes = { email: { type: DataTypes.STRING(255), allowNull: false, unique: true } };UniqueConstraintError; passunique: { name: 'uq_email', msg: 'That email is taken' } to control thereferences:const attributes = {
orgId: {
type: DataTypes.INTEGER(),
references: { model: 'orgs', key: 'id', onDelete: 'CASCADE', onUpdate: 'RESTRICT' },
},
};onDelete / onUpdate written as siblings of references are ignored —FOREIGN KEY (`orgId`) REFERENCES `orgs` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,sync(); seeUUID primary keyUUID child column rather than an INTEGER one.await prorm.disableForeignKeyChecks();
// … load data in any order …
await prorm.enableForeignKeyChecks();finally, so a failed batch doesimport { readFileSync } from 'node:fs';
const orgs = JSON.parse(readFileSync('./export/orgs.json', 'utf8'));
const users = JSON.parse(readFileSync('./export/users.json', 'utf8'));
await prorm.disableForeignKeyChecks();
try {
// Children before parents is fine while the checks are off.
await prorm.getQueryInterface().bulkInsert('users', users);
await prorm.getQueryInterface().bulkInsert('orgs', orgs);
} finally {
await prorm.enableForeignKeyChecks();
}
// SQLite: list every row whose parent is missing. Empty means the load is sound.
const orphans = await prorm.query('PRAGMA foreign_key_check');
if (orphans.length > 0) throw new Error(`${orphans.length} rows reference missing parents`);| Dialect family | SQL emitted |
|---|---|
| SQLite, Turso | PRAGMA foreign_keys = OFF / ON |
| MySQL, MariaDB, TiDB | SET FOREIGN_KEY_CHECKS = 0 / 1 |
| PostgreSQL, TimescaleDB, Greenplum, YugabyteDB, CockroachDB | SET session_replication_role = 'replica' / 'origin' |
| everything else | throws UnsupportedForeignKeyChecksError |
GRANT SET ON PARAMETER
session_replication_role on PG 15+. That is deliberately not swallowed — thepermission denied to set parameter surfaces so you know the checksSession-level foreign key checks are not supported on mssql.
Use ALTER TABLE <table> NOCHECK CONSTRAINT ALL …NOCHECK CONSTRAINT ALL, Oracle → DISABLE CONSTRAINT, Db2 →SET INTEGRITY; Redshift, Snowflake, ClickHouse, SingleStore and Databricks saygetDisableForeignKeyChecksSQL() / getEnableForeignKeyChecksSQL() return thedisableUniqueKeyChecks() / enableUniqueKeyChecks() (SET UNIQUE_CHECKS =
0/1); those stay deliberate no-ops elsewhere, since SET UNIQUE_CHECKS is aawait prorm.addIndex('users', 'idx_age', ['age']);
await prorm.removeIndex('users', 'idx_age');
await prorm.addColumn('users', 'nickname', { type: DataTypes.STRING(50) });
await prorm.changeColumn('users', 'nickname', { type: DataTypes.STRING(100) });
await prorm.removeColumn('users', 'nickname');
await prorm.renameTable('users', 'people');
await prorm.describeTable('users');users table withid, orgId, email, status and age, the ck_age and uq_org_emailidx_status_age and idx_active indexes:removeColumn and changeColumn rebuild the table, because SQLite[!CAUTION]
Compare the rebuilt SQLite table with the original: theREFERENCES "orgs"
clause,CONSTRAINT "ck_age" CHECKandCONSTRAINT "uq_org_email" UNIQUEare
gone. A directchangeColumn()orremoveColumn()on SQLite leaves the table
without its foreign keys and table constraints.sync({ alter: true })puts
declared unique keys back as unique indexes afterwards (below); for anything
else, rebuild the table yourself in a migration — create the new table with
every constraint, copy, drop, rename — rather than altering it.
addIndex emits a plain CREATE INDEX, which on PostgreSQL locks the tableCONCURRENTLY builds without that lock but refuses to run inside a transactionprormQuery:[!WARNING]
ACREATE INDEX CONCURRENTLYthat fails part-way leaves an invalid index
behind, andIF NOT EXISTSwill then skip it on the next run. Checkpg_index.indisvalidafter a failure and drop the invalid index before
retrying.
addIndex, each dialect exposes the index kinds it actually supports.prorm.getDialectInstance().| Method | Purpose |
|---|---|
createIndex / dropIndex | Plain index. |
createPartialIndex(table, name, fields, predicate) | Index with a literal WHERE predicate. |
createExpressionIndex(table, name, expression) | CREATE INDEX … (LOWER(body)). |
createFulltextIndex(table, name, fields) | Dialect-native full-text index. |
createSpatialIndex(...) | GiST / SPATIAL / RTree index. |
createGINIndex, createGISTIndex, createVectorIndex | PostgreSQL-specific. |
addConstraint / removeConstraint | Constraint on an existing table. |
showIndexes(table) / showConstraints(table) | Introspection. |
import { fn, col } from 'ts-prorm-orm';
await qi.addIndex('docs', [], { name: 'idx_lower_body', expression: fn('LOWER', col('body')) });
// CREATE INDEX IF NOT EXISTS "idx_lower_body" ON "docs" ((LOWER("body")))
const dialect = prorm.getDialectInstance();
await dialect.createExpressionIndex('docs', 'idx_lower_body', 'LOWER(body)'); // the raw formaddConstraint type "CHECK" cannot be added to an existing SQLite table;
define it when the table is created (SQLite only supports UNIQUE via a unique
index post-hoc).buildCreateFullTextIndexSQL(options, dialect, quoteId, quoteTable) resolvesimport { buildCreateFullTextIndexSQL } from 'ts-prorm-orm';
const q = (i: string) => `"${i}"`;
buildCreateFullTextIndexSQL(
{ name: 'ft_docs', table: 'docs', columns: ['title', 'body'] },
'postgres', q, q,
);| Dialect | SQL |
|---|---|
| PostgreSQL | CREATE INDEX "ft_docs" ON "docs" USING GIN (to_tsvector('english', coalesce("title",'') || ' ' || coalesce("body",''))) |
| MySQL / MariaDB | CREATE FULLTEXT INDEX "ft_docs" ON "docs" ("title", "body") |
| SQLite | CREATE VIRTUAL TABLE IF NOT EXISTS "ft_docs" USING fts5("title", "body", content="docs") |
| SQL Server | throws UnsupportedSchemaObjectError — needs a full-text catalog and unique key index first |
| Oracle | throws UnsupportedSchemaObjectError |
language selects the PostgreSQL text-search configuration (default'english'). The thrown error is deliberate: it tells you the operation isprorm.define('Membership', {
userId: { type: DataTypes.INTEGER() },
teamId: { type: DataTypes.INTEGER() },
role: { type: DataTypes.STRING(20) },
}, { primaryKey: ['userId', 'teamId'] });Model.getPrimaryKeyAttribute() reports the resolved key, and it is whatfindByPk and findInBatches iterate on.