Type-safe triggers in an ORM. What?


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.
The audit log, on the entity
Section titled “The audit log, on the entity”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' } }.
What the compiler catches
Section titled “What the compiler catches”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.
One declaration, every SQL engine
Section titled “One declaration, every SQL engine”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 ROWWHEN (OLD."status" IS DISTINCT FROM NEW."status")EXECUTE FUNCTION "_uql_Post__audit_0a57e9"()CREATE TRIGGER `_uql_Post__audit_94c077`AFTER UPDATE ON `Post`FOR EACH ROWBEGIN IF NOT (OLD.`status` <=> NEW.`status`) THEN INSERT INTO `PostAudit` (`post_id`, `from_status`, `to_status`) VALUES (NEW.`id`, OLD.`status`, NEW.`status`); END IF;ENDCREATE TRIGGER `_uql_Post__audit_08d35c`AFTER UPDATE OF `status` ON `Post`FOR EACH ROWWHEN (OLD.`status` IS NOT NEW.`status`)BEGIN INSERT INTO `PostAudit` (`post_id`, `from_status`, `to_status`) VALUES (NEW.`id`, OLD.`status`, NEW.`status`);ENDCREATE TRIGGER "_uql_Post__audit_d8fbff"ON "Post" AFTER UPDATEASBEGIN SET NOCOUNT ON; INSERT INTO "PostAudit" ("post_id", "from_status", "to_status") SELECT inserted."id", deleted."status", inserted."status" FROM inserted JOIN deleted ON inserted."id" = deleted."id" WHERE EXISTS (SELECT deleted."status" EXCEPT SELECT inserted."status");END“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.
How other ORMs do it
Section titled “How other ORMs do it”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 postFOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status)EXECUTE FUNCTION post_audit();// Declared on the entity and installed by migrations. The callback maps property names to// column names; the body and `when` around them are SQL strings, one set per dialect.import { defineEntity, p } from '@mikro-orm/core';
const PostSchema = defineEntity({ name: 'Post', properties: { id: p.integer().primary(), status: p.string().nullable(), }, triggers: [ { name: 'post_audit', timing: 'after', events: ['update'], when: 'OLD.status IS DISTINCT FROM NEW.status', body: (c) => `INSERT INTO post_audit (post_id, from_status, to_status) VALUES (NEW.${c.id}, OLD.${c.status}, NEW.${c.status}); RETURN NULL;`, }, ],});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.
Keeping them in sync
Section titled “Keeping them in sync”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.
Where it stops
Section titled “Where it stops”- MongoDB has no triggers, so a write to an entity declaring one is refused rather than made without it.
rawbodies 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
securitydo not scope its writes, so scope them by the row’s own columns.
The engine-specific rules are on the Triggers page.
Why it matters with coding agents
Section titled “Why it matters with coding agents”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.
Hook, trigger or stamp
Section titled “Hook, trigger or stamp”| 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 updated |
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;}Get started
Section titled “Get started”- 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.