Skip to content

Type-safe triggers in an ORM. What?

A @Trigger declaration on a Post entity, the writers it fires for (uql, psql, a backfill, another service), and the four SQL engines it is rendered forA @Trigger declaration on a Post entity, the writers it fires for (uql, psql, a backfill, another service), and the four SQL engines it is rendered for

An audit log written by a lifecycle hook passes every test. Then someone runs a backfill, fixes a row in psql, or points a second service at the table, and the log has a gap. Nothing failed: an ORM hook only sees the ORM’s own writes.

A database trigger sees every write. In UQL you declare it on the entity, the compiler checks its body, and uql renders it for each SQL engine.

Triggers usually live as SQL strings in an old migration: invisible from the model, unchecked against the columns, and written again for every engine. Here the trigger sits beside the table it watches:

import { Entity, Field, Id, insertInto, Trigger } from 'uql-orm';
@Entity()
export class PostAudit {
@Id({ type: Number })
id?: number;
@Field({ type: Number, name: 'post_id', nullable: false })
postId?: Post['id'];
@Field({ type: String, name: 'from_status' })
fromStatus?: Post['status'];
@Field({ type: String, name: 'to_status' })
toStatus?: Post['status'];
}
@Trigger({
on: 'afterUpdate',
name: 'audit',
of: (post) => [post.status],
run: (newRow, oldRow) =>
insertInto(PostAudit, {
postId: newRow.id,
fromStatus: oldRow.status,
toStatus: newRow.status,
}),
})
@Entity()
export class Post {
@Id({ type: Number })
id?: number;
@Field({ type: String })
status?: string | null;
}

of fires the trigger only when status changes, and run writes the audit row. Columns are read as properties, so the compiler knows every name and type. A where adds conditions on either row, such as { $old: { status: 'draft' } }.

Each of these fails at build time, not when the trigger first fires:

import { defineTrigger, insertInto } from 'uql-orm';
defineTrigger(Post, {
on: 'afterInsert',
run: (_newRow, oldRow) => insertInto(PostAudit, { postId: oldRow.id }), // error: no old row on insert
});
defineTrigger(Post, {
on: 'afterUpdate',
run: (newRow) => insertInto(PostAudit, { post: newRow.id }), // error: PostAudit has no `post`
});
defineTrigger(Post, {
on: 'afterUpdate',
run: (newRow) => insertInto(PostAudit, { postId: newRow.status }), // error: postId holds a number
});

So does watching a column the entity lacks, or an updateTable or deleteFrom with no $where, which would hit every row on every fire.

This is what sync and generated migrations install. CockroachDB gets the Postgres form, MariaDB the MySQL one:

CREATE OR REPLACE FUNCTION "_uql_Post__audit_0a57e9"() RETURNS trigger AS $uql$
BEGIN
INSERT INTO "PostAudit" ("post_id", "from_status", "to_status")
VALUES (NEW."id", OLD."status", NEW."status");
RETURN NULL;
END $uql$ LANGUAGE plpgsql;
CREATE TRIGGER "_uql_Post__audit_0a57e9"
AFTER UPDATE OF "status" ON "Post"
FOR EACH ROW
WHEN (OLD."status" IS DISTINCT FROM NEW."status")
EXECUTE FUNCTION "_uql_Post__audit_0a57e9"()

“Fire only when status changes” has to count NULL to 'draft' as a change, which a plain <> does not. Each engine spells that differently, and SQL Server hands the trigger tables rather than rows: four chances to get it subtly wrong by hand.

The same trigger, on PostgreSQL only:

-- No trigger API. This goes in a hand-written migration: `drizzle-kit generate --custom`,
-- `prisma migrate dev --create-only`, or `queryRunner.query(...)` in a TypeORM migration.
CREATE FUNCTION post_audit() RETURNS trigger AS $$
BEGIN
INSERT INTO post_audit (post_id, from_status, to_status)
VALUES (NEW.id, OLD.status, NEW.status);
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER post_audit AFTER UPDATE OF status ON post
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION post_audit();

MikroORM comes closest: the trigger lives on the entity and takes column names from its metadata, but the SQL around them is a string, unchecked and written once per engine. TypeORM’s subscribers, like hooks, only see TypeORM’s own writes.

sync and generate:entities install what an entity declares and drop what it no longer does. Each name ends in a hash of its SQL, so an unchanged trigger is left alone and an edited one is replaced. Triggers uql did not create are never touched.

  • MongoDB has no triggers, so a write to an entity declaring one is refused rather than made without it.
  • raw bodies are not translated: write one that runs on every engine you use, or one per engine.
  • Entity filters do not apply inside a trigger: soft delete and security do not scope its writes, so scope them by the row’s own columns.

The engine-specific rules are on the Triggers page.

Agents write much of the code that touches a database, including what hooks never see: a one-off script, a backfill, a quick UPDATE. A rule the database enforces holds for all of it, and a trigger on the entity sits in the file the agent reads, checked by the compiler it runs.

You need Use
Logic around UQL’s own writes: call an API, publish an event, validate a lifecycle hook
A row the database writes whoever changes the table, like an audit a trigger
A column the database keeps current on every write, like updatedAt a stamp

A stamp is a trigger uql writes the body of. currentTimestamp is the database clock, in UTC to the millisecond on every engine:

import { currentTimestamp, Entity, Field, Id } from 'uql-orm';
@Entity()
export class Post {
@Id({ type: Number })
id?: number;
@Field({ type: Date, computed: currentTimestamp, stored: ['update'] })
readonly updatedAt?: Date | null;
}
  • Triggers: every option, the SQL per engine, and the engine notes
  • Comparison: triggers and the rest of the feature matrix, ORM by ORM
  • GitHub

If a trigger you need cannot be declared this way, open an issue with the SQL you would have written.