ent-framework
  • Ent Framework
  • Getting Started
    • Code Structure
    • Connect to a Database
    • Create Ent Classes
    • VC: Viewer Context and Principal
    • Ent API: insert*()
    • Built-in Field Types
    • Ent API: load*() by ID
    • N+1 Selects Solution
    • Automatic Batching Examples
    • Ent API: select() by Expression
    • Ent API: loadBy*() Unique Key
    • Ent API: update*()
    • Ent API: deleteOriginal()
    • Ent API: count() by Expression
    • Ent API: exists() by Expression
    • Ent API: selectBy() Unique Key Prefix
    • Ent API: upsert*()
    • Privacy Rules
    • Validators
    • Triggers
    • Custom Field Types
  • Ent API: Configuration and Types
  • Scalability
    • Replication and Automatic Lag Tracking
    • Sharding and Microsharding
    • Sharding Terminology
    • Locating a Shard and ID Format
    • Sharding Low-Level API
    • Shard Affinity and Ent Colocation
    • Inverses and Cross Shard Foreign Keys
    • Shards Rebalancing and pg-microsharding Tool
    • Connection Pooling
  • Advanced
    • Database Migrations and pg-mig Tool
    • Ephemeral (Symbol) Fields
    • Atomic Updates and CAS
    • Custom Field Refactoring
    • VC Flavors
    • Query Cache and VC Caches
    • Loaders and Custom Batching
    • PostgreSQL Specific Features
    • Query Planner Hints
    • Cluster Maintenance Queries
    • Logging and Diagnostic Tools
    • Composite Primary Keys
    • Passwords Rotation
  • Architecture
    • Abstraction Layers
    • Ent Framework, Meta’s TAO, entgo
    • JIT in SQL Queries Batching
    • To JOIN or not to JOIN
Powered by GitBook
On this page
  • Before-Triggers
  • beforeInsert Triggers
  • Accessing Ent ID in beforeInsert Trigger
  • beforeUpdate Triggers
  • Immutable Fields
  • beforeDelete Triggers
  • beforeMutation Triggers
  • Changed Fields Tracking and React's useEffect() Analogy
  • After-Triggers
  • afterInsert Triggers
  • afterUpdate Triggers
  • afterDelete Triggers
  • afterMutation Triggers

Was this helpful?

Edit on GitHub
  1. Getting Started

Triggers

Triggers are hooks that Ent Framework executes right before or after a mutation (insert, update or delete). They can modify the table row before it's got saved, and also load or update other Ents.

The word "hook" also draws the analogy with React Hooks (from frontend world), since update-triggers in Ent Framework have several traits in common with React's useEffect() hook.

Triggers are defined in the Ent Class configuration, near Privacy Rules.

Before-Triggers

In before-triggers, you can:

  1. Make changes in the fields right before they are saved to the database.

  2. Load or even mutate other Ents.

const schema = new PgSchema(
  "topics",
  {
    id: { type: ID, autoInsert: "nextval('topics_id_seq')" },
    created_at: { type: Date, autoInsert: "now()" },
    updated_at: { type: Date, autoUpdate: "now()" },
    slug: { type: String, autoInsert: "NULL" },
    creator_id: { type: ID },
    subject: { type: String, allowNull: true },
  },
  ["slug"]
);

export class EntTopic extends BaseEnt(cluster, schema) {
  static override configure() {
    return new this.Configuration({
      ...
      beforeInsert: [...],
      beforeUpdate: [...],
      beforeDelete: [...],
      beforeMutation: [...],
    });
  }
}

beforeInsert Triggers

Let's start with an example:

...
beforeInsert: [
  async (vc, { input }) => {
    let slug = slugufy(input.subject);
    if (await EntTopic.exists(vc, { slug })) {
      slug += `-${Date.now()}`;
    }
    input.slug = slug;
  },
],
...
const topic = await EntTopic.insertReturning(vc, {
  creator_id: "123",
  subject: "My Topic",
});  

Here, we automatically assign the value to slug field of the inserted row based on the topic's subject.

Notice that it requires a little quirk though: slug field in the schema needs to be defined with autoInsert attribute, otherwise Ent Framework TypeScript typing will make slug as a required property in `insertReturning() call.

As everything in Ent Framework, all arguments of the trigger functions are strongly typed.

  • It will respect the field types defined in the schema exactly.

  • Nullability is respected (fields defined with allowNull: true will be nullable in the input argument).

  • It will pay attention to required and optional fields (the optional fields are the ones defined with autoInsert or autoUpdate).

Accessing Ent ID in beforeInsert Trigger

Despite the insert operation has not yet been applied to the database, in all beforeInsert triggers, you can already read the ID of the Ent to be inserted.

This is very convenient to organize eventually consist logic in your code: in Ent Framework, there are no transactions exposed (and there can be no transactions even in theory when working across microshards or across different storage services), so you must pay attention to the order of the writes, to make sure your don't lose eventual consistency behavior:

...
beforeInsert: [
  async (vc, { input }) => {
    await addToKafka(this.name, input.id);
  },
],
...

Here we assume that you have a addToKafka() function which accepts the Ent class name and the Ent ID. After the write to Kafka succeeds, you proceed with saving the Ent to the database. Using this apprpach, you can e.g. implement eventually-consistent pipelining of the Ent data to some other storage using an external bus (like Kafka or Redis Streams), despite this system "bus+PostgreSQL" being not transactionally safe as a whole.

beforeUpdate Triggers

Update is a more complicated operation, since you have the old row and the new row versions at the same time.

...
beforeUpdate: [
  async (vc, { oldRow, input, newRow }) => {
    await addToKafka(this.name, newRow.id);
    if (newRow.subject !== oldRow.subject) {
      // Notice that newRow.subject is a non-optional
      // string property, whilst input.slug is optional
      // (i.e. string | undefined).
      let slug = slugufy(newRow.subject);
      if (await EntTopic.exists(vc, { slug })) {
        slug += `-${Date.now()}`;
      }
      input.slug = slug;
    }
  },
],
...
await topic.updateReturningX({ subject: "Hello" });

Notice that the code here is very similar to the beforeInsert trigger we discussed above. To avoid boilerplate in such cases, you can use beforeMutation instead; we'll describe it a little later.

In the trigger functions, Ent Framework gives you the following arguments:

  • oldRow: the row with Ent fields right before the update. This object is immutable.

  • input: properties passed to update*() method as they are. Notice that it includes not all Ent fields, but only the fields you are mutating (in other words, all properties of input object are optional in their TypeScript typing). You need to modify this object if you want the trigger to make changes in the Ent before the update happens.

  • newRow: the result of applying input over oldRow . This is an immutable object.

Immutable Fields

Using beforeUpdated, you can force some Ent field to be immutable, so any update*() call will not change it:

...
beforeUpdate: [
  function SlugIsImmutable(vc, { oldRow, input }) => {
    input.slug = oldRow.slug;
  },
],
...
// This value won't be saved.
await topic.updateReturningX({ slug: "new-value" });

beforeDelete Triggers

This kind of triggers is the simplest:

...
beforeDelete: [
  async (vc, { oldRow }) => {
    await addToKafka(this.name, oldRow.id);
    await mapJoin(
      await EntComment.select(vc, { topic_id: oldRow.id }, 1000000),
      async (comment) => comment.deleteOriginal(),
    );
  },
],
...
await topic.deleteOriginal();

In this example, we do two things:

  1. We call addToKafka() function to e.g. publish the deletion event to our event bus, so we can replay that deletion to some other data store in an eventually consistent manner. If publishing to Kafka fails, them the trigger will throw an error, and no deletion will happen in the database. If deletion succeeds, then we can be sure that it also got replayed to Kafka (since it's done prior to the deletion). And if deletion fails... then the user will see it and retry later.

  2. We delete all children comments when the topic is deleted. This is a kind of ON DELETE CASCADE clause in relational database's foreign keys, but with an important difference: it calls Ent Framework triggers on the comments as well.

beforeMutation Triggers

Notice that we have some boilerplate in our triggers:

  • We call addToKafka() in 3 places: beforeInsert/Update/Delete triggers.

  • We have the exact same logic to calculate slug field in 2 places: beforeInsert/Update .

To eliminate that, there is a special feature: beforeMutation triggers, which are called before any mutation (be it insert, update or delete), in a TypeScript-safe way for the arguments.

...
beforeMutation: [
  async (vc, { newOrOldRow }) => {
    await addToKafka(this.name, newOrOldRow.id);
  },
  async (vc, { op, newOrOldRow, input }) => {
    if (
      op === "INSERT" ||
      (op === "UPDATE" && "subject" in input && newOrOldRow.subject !== input.subject)
    ) {
      let slug = slugufy(newOrOldRow.subject);
      if (await EntTopic.exists(vc, { slug })) {
        slug += `-${Date.now()}`;
      }
      input.slug = slug;
    }
  },
],
beforeDelete: [
  async (vc, { oldRow }) => mapJoin(
    await EntComment.select(vc, { topic_id: oldRow.id }, 1000000),
    async (comment) => comment.deleteOriginal(),
  ),
],
...
const topic = await EntTopic.insertReturning(vc, {
  creator_id: "123",
  subject: "My Topic",
});
await topic.updateReturningX({ subject: "Hello" });
await topic.deleteOriginal();

There are 2 gotchas here:

  1. We split one big trigger into two independent ones. The triggers are run sequentially, and the next trigger in the list is not called if the previous one throws an error.

  2. TypeScript is smart enough to understand that, when you check op against "INSERT" or "UPDATE" strings, the typing of newOrOldRow and input arguments will be according to the operation types (i.e. it will respect optional properties for instance).

Changed Fields Tracking and React's useEffect() Analogy

But you are probably still not satisfied with that long if clause in the example above. We can improve the code:

...
beforeMutation: [
  async (vc, { newOrOldRow }) => {
    await addToKafka(this.name, newOrOldRow.id);
  },
  [
    (vc, row) => [row.subject], // "deps builder"
    async (vc, { op, newOrOldRow, input }) => {
      if (op !== "DELETE") {
        let slug = slugufy(newOrOldRow.subject);
        if (await EntTopic.exists(vc, { slug })) {
          slug += `-${Date.now()}`;
        }
        input.slug = slug;
      }
    },
  ],
],
...

Here we pass a tuple with 2 lambdas:

  1. The 1st lambda, (vc, row) => [row.subject], is called "deps builder". It extracts some part of the row, and Ent Framework will call the trigger code only if that part has actually changed on an update (and also on insert/delete, since those are also considered as "changes")

  2. The 2nd lambda is your trigger code. Ent Framework will run it only if the 1st callback returned a value different between the old and the new rows (or it's an insert or delete operation). If you are familiar with React, you can notice that this mechanism is similar to how its useEffect() hook works.

In the trigger code, you still need to check that the operation is not DELETE, but it is way better still than having a boilerplate in the previous example.

The "deps builder" lambda can be async, so you can run other database queries in it and make decisions based on their results.

Notice that "deps builder" tuple also works for beforeUpdate, as well as for afterUpdate and afterMutation triggers we'll discuss below.

After-Triggers

After-triggers are called seqentially, as soon as an insert/update/delete mutation succeeds in the database.

afterInsert Triggers

Triggers of this kind act exactly as beforeInsert, but they are called after a successful database operation, not before. There, you can do some auxiliary work, but keep in mind that, if this work fails, the Ent will remain created in the database still. There are no (and cannot be) built-in transactions across multiple independent IO services and multiple different microshards.

...
afterInsert: [
  async (vc, { input }) => {
    ...
  },
],
...

afterUpdate Triggers

The only difference with beforeUpdate triggers here is that there is no input argument passed: the only things you have are oldRow and newRow :

...
afterUpdate: [
  async (vc, { oldRow, newRow }) => {
    ...
  },
],
...

You can also use "deps builder" syntax in afterUpdate, to run the trigger code only if some particular fields change:

...
afterUpdate: [
  ...
  [
    (vc, row) => [row.subject], // "deps builder"
    async (vc, { oldRow, newRow }) => {
      ...
    },
  ],
],
...

afterDelete Triggers

In afterDelete, you can run some optional cleanup of other resources associated to the just-deleted Ent. Keep in mind though that it's all non-transactional: if your cleanup fails, it won't be retried, and the row will already be deleted in the database.

...
afterDelete: [
  async (vc, { oldRow }) => {
    ...
  },
],
...

afterMutation Triggers

Similarly to beforeMutation triggers, afterMutation triggers allow you to react on any of insert/update/delete operations. but only after this operation succeeds in the database.

There is also no input argument available in this kind of triggers, only newOrOldRow.

...
afterMutation: [
  async (vc, { op, newOrOldRow }) => {
    ...
  },
],
...

You can use "deps builder" syntax too if you want to react only when some particular fields change on an update (or on insert and delete unconditionally):

...
afterMutation: [
  [
    (vc, row) => [row.subject], // "deps builder"
    async (vc, { op, newOrOldRow }) => {
      ...
    },
  ],
],
...
PreviousValidatorsNextCustom Field Types

Last updated 1 month ago

Was this helpful?