5

Adding Hooks and Lifecycle Events to Your Models

ORM typically provides a way for us to be able to transform states, or objects, throughout events that occur when executing certain operations. These methods are often referred to as hooks, lifecycle events, object lifecycles, or even callbacks (the latter is not often used within the Node.js community due to a nomenclature conflict against Node.js’ native environment). Usually, these methods have a temporal prefix (for example, before and after) preceding an event’s name.

There are no strict rules as to what an ORM requires as an event throughout its entire lifecycle. The events typically included within an ORM are called: validation, save, create, update, and destroy. Other ORM frameworks offer a wider scope of events or more granular control, such as before/after connecting to your database, defining your models, and calling a finder query.

Sequelize categorizes hooks into global and local hooks. Global hooks are for defining default lifecycle events for every model, enforce events (referred to as permanent hooks in Sequelize), and connection-related events. The local hooks entail lifecycle events defined on models for instances/records.

In this chapter, we will go over the following:

  • The order of operations for lifecycle events
  • Defining, removing, and executing lifecycle events
  • Using lifecycle events with associations and transactions

Note

You can always reference Sequelize’s code base to maintain an up-to-date list of available lifecycle events here: https://sequelize.org/docs/v6/other-topics/hooks/.

Technical requirements

You can find the code files for this chapter at https://github.com/PacktPublishing/Supercharging-Node.js-Application-with-Sequelize/blob/main/ch5.

Order of operations for lifecycle events

Lifecycle events are an important feature when we want to introduce project-specific behaviors/constraints that extend beyond a database engine’s scope. Knowing the lifecycle events is only half of the equation, and the other half consists of knowing when those lifecycle events are triggered.

Suppose we were given the task to offer all of our products for free to employees. The first action could be adding a beforeValidate hook that would set the transaction’s subtotal to 0 if the user was an employee. That’s easy for us, but unfortunately a nightmare for the accounting department. A better approach would be to add an additional item that represents the employee discount, using the beforeValidate or beforeCreate hook.

The real answer in knowing which lifecycle events to use depends on the project’s requirements. From our previous example, some transactions require moving legal tender, which involves charging the employee and then providing a refund/credit as a separate transaction. In this case, we would not be able to use beforeValidate nor beforeCreate, but afterCreate could be applicable. Under the context of Sequelize, knowing where to place your code’s logic is knowing the order of operations for lifecycle events.

In Sequelize, lifecycle events follow the before/after preface style for hook names, like other ORM frameworks. All of Sequelize’s connection lifecycle events are defined on the sequelize object itself, and all of the instance event types are defined on models. The model event types can be defined in both areas. The exception to these rules is when we want to define an instance event for all of the models globally (examples will be provided in the following section). Here is a table listing lifecycle events in the order that they are executed along with a signature for the callback function:

Hook definitions sorted by lifecycle execution

Event name

Event type

Requires sync*

beforeConnect(config)

beforeDisconnect(connection)

Connection

No

beforeSync(options)

afterSync(options)

Connection

No

beforeBulkSync(options)

afterBulkSync(options)

Connection

No

beforeQuery(options, query)

Connection

No

beforeDefine(attributes, options)

afterDefine(factory)

Connection (Model)

Yes

beforeInit(config, options)

afterInit(sequelize)

Connection (Model)

Yes

beforeAssociate({ source, target, type }, options)

afterAssociate({ source, target, type, association }, options)

Connection (Model)

Yes

beforeBulkCreate(instances, options)

beforeBulkDestroy(options)

beforeBulkRestore(options)

beforeBulkUpdate(options)

Model

No

beforeValidate(instance, options)

Instance

No

afterValidate(instance, options)

validationFailed(instance, options, error)

Instance

No

beforeCreate(instance, options)

beforeDestroy(instance, options)

beforeRestore(instance, options)

beforeUpdate(instance, options)

beforeSave(instance, options)

beforeUpsert(values, options)

Instance

No

afterCreate(instance, options)

afterDestroy(instance, options)

afterRestore(instance, options)

afterUpdate(instance, options)

afterSave(instance, options)

afterUpsert(created, options)

Instance

No

afterBulkCreate(instances, options)

afterBulkDestroy(options)

afterBulkRestore(options)

afterBulkUpdate(options)

Instance

No

afterQuery(options, query)

Connection

No

beforeDisconnect(connection)

afterDisconnect(connection)

Connection

No

*These lifecycle events will be triggered only if sequelize.sync() is invoked.

The majority of these lifecycle events are explicative in corresponding with their Sequelize function (for example, beforeSave for Model.save()). However, there are two types of events that are implicative and may not be clear initially. The first one is the restore events related to paranoid models (where records are considered delete with a column flag as opposed to being physically deleted). The second one is the Upsert events that are invoked for create, update, and save-related methods, indicating to us whether a record was newly created or updated from a pre-existing record.

Where Sequelize differentiates from other ORM lifecycle events is, in addition to instance and connection-related events, Sequelize will also provide hooks surrounding finder methods (for example, findAll and findOne). The following is a list with a brief explanation of each finder event:

  • beforeFind(options): Occurs before any transformation that occurs to options from Sequelize internally
  • beforeFindAfterExpandIncludeAll(options): An event that is triggered after Sequelize expands the include attributes (for example, setting proper defaults for specific associations)
  • beforeFindAfterOptions(options): Before the finder method invokes the query and after Sequelize is finished hydrating/transforming options
  • afterFind(instances, options): Returns a single instance or an array of instances after a finder method is finished querying
  • beforeCount(options): This event will trigger before the count() instance method queries the database

Now that we have a better understanding of which hooks are available to use and the order of execution through the lifecycle, we can begin building our models with lifecycle events attached to them.

Defining, removing, and executing lifecycle events

There are several ways to attach lifecycle events to models and Sequelize’s behavior. Each of these methods allows us to change the attribute values that are derived from the hook’s arguments as pass-by-reference. For example, you can add additional properties to the instances returned in afterFind by simply updating the attributes on the objects from within the lifecycle method. By default, Sequelize will treat lifecycle events as synchronous operations, but if you need asynchronous capabilities, you can return a Promise object or an async function.

Defining instance and model lifecycle events

Instance and model lifecycle events can be defined in several ways, including defining these events as a local hook (defined directly from the model itself). There are several ways to define a local hook; we will start with the basic example of declaring hooks during the initialization of a model:

class Receipt extends Model {}
Receipt.init({
  subtotal: DataTypes.DECIMAL(7, 2)
}, {
  hooks: {
    beforeValidate: (receipt, options) => {
      if (isEmployee(receipt.customer)) {
        receipt.subtotal = 0;
      }
    }
  }
});
// or with the define() method
sequelize.define('Receipts', {
  subtotal: DataTypes.DECIMAL(7, 2)
}, {
  hooks: {
    beforeValidate(receipt, options) => { … })
  }
});

To define the same exact hook outside of initialization, we can either use the addHook() method or invoke the corresponding lifecycle method directly. This method provides an easy way for plugins and adapters to integrate with your models after defining them. The following is a simple example of how to use this method:

function employeeDiscount(receipt, options) {
  if (isEmployee(receipt.customer)) {
    receipt.subtotal = 0;
  }
}
class Receipt extends Model {}
Receipt.init({ subtotal: DataTypes.DECIMAL(7, 2) });
Receipt.addHook('beforeValidate',employeeDiscount);
// or you can use the direct method:
Receipt.beforeValidate(employeeDiscount);

The previous examples provided illustrations for synchronous events. An example of asynchronous hooks involves returning a Promise (as previously stated), like so:

async function employeeDiscount(receipt, options) {
  if (!customerIsEmployee) {
    return;
  }
  const discountTotal = await 
  getDiscountFromExternalAccountingService(employeeId);
  receipt.subtotal = discountTotal;
}
Receipt.addHook('beforeValidate', employeeDiscount);
// or…
Receipt.beforeValidate(employeeDiscount);

To throw an error from a synchronous lifecycle event, you can return a rejected Promise object:

Receipt.beforeValidate((receipts, options) => {
  return Promise.reject(new Error("Invalid receipt"));
});

For organizational purposes, you can declare names for your lifecycle events using the addHook() or direct methods:

Receipt.addHook('beforeValidate', 'checkForNegativeSubtotal', (receipt, options) => { … });
// or
Receipt.beforeValidate('checkForNegativeSubtotal', (receipt, options) => {…});

These examples provide us with methods for assigning lifecycle events on the local scope of the model itself. If we wanted to define lifecycle events on a global scope (applicable to all models), we would use the Sequelize constructor to do so:

const sequelize = new Sequelize(…, {
  define: {
    hooks: {
      beforeValidate() {
     // perform some kind of data transformation/validation
      }
  }
});

This will generate a default beforeValidate hook for models that do not define their own beforeValidate hooks. If you wish to run a global hook, regardless of whether the model has its own definition, we can define permanent hooks:

sequelize.addHook('beforeValidate', () => { … });

Even if a model has its own beforeValidate hook definition, Sequelize will still execute the global hook. If we have a global and local hook associated with the same lifecycle event, then Sequelize will execute the local hook(s) first followed by the global hook(s).

For model-specific event types (such as bulkDestroy and bulkUpdate), Sequelize will not execute individual delete and update hooks per row by default. To modify this behavior, we can add a { individualHooks: true } option for when we call these methods, like so:

await Receipt.destroy({
  where: { … },
  individualHooks: true
});

Note

Using the { indvidualHooks: true } option could cause a decrease in performance, depending on whether Sequelize will need to retrieve rows, store the rows/additional information in memory (for example, bulkDestroy and bulkUpdate but not bulkCreate), and execute individual hooks per record.

Removing lifecycle events

Some projects will require conditionally invoking lifecycle events. For instance, we may have some sort of validation to check whether a user is still eligible for replying to a comment on a forum. This validation is appropriate for a production environment but not necessary for a development environment.

One method would be to create a conditional logic surrounding the hook definition – for example, the following:

if (!isDev) {
  User.addHook('beforeValidate', 'checkForPermissions', …);
}

This would technically work, but what if we had several stipulations, such as sending an order email in the afterCreate hook or refunding an order in production only? We would have a lot of “if statements” throughout the code base. Sequelize offers a method to remove lifecycle events to help organize this type of workflow called removeHook.

We could load all of the lifecycle events as we normally would, but if our environment is at the development stage, then we can run through all of our models and remove the applicable hooks. All of these granular tunings can be organized in one function given the removeHook method:

function removeProductionOnlyHooks() {
  // this will remove all matching hooks by event type and 
     name
  User.removeHook('beforeValidate', 'checkForPermissions');
  // this will remove all beforeValidate hooks on the User 
     model
  User.removeHook('beforeValidate');
  // this will remove all of the User model's hooks
  User.removeHook();
}
 // load our models…
if (isDev) {
  removeProductionHooksOnly();
}

Removing lifecycle events is useful for timed behavior in an application or for removing explicit debugging hooks. The next section will help us understand the order of operation when executing lifecycle events and when a specific lifecycle event will be executed.

Executing lifecycle events

Sequelize will run the corresponding/applicable lifecycle events based on the method that you are invoking. Using our previous Transactions model example, if we were to run Transactions.create({ … }), then Sequelize would automatically run the following lifecycle events (in sequential order):

  1. beforeValidate
  2. afterValidate/validationFailed
  3. beforeCreate
  4. beforeSave
  5. afterSave
  6. afterCreate

One caveat to keep in mind for executing lifecycle events is that when you are using the update() method, it is important to keep in mind that Sequelize will not execute the lifecycle events unless an attribute’s value has changed.

For instance, this will not call the corresponding lifecycle events:

var post = await Post.findOne();
await Post.update(post.dataValues, {
  where: { id: post.id }
});

Since the values did not change, Sequelize will ignore the lifecycle events. If we wanted to enforce this behavior, we could add a hooks: true parameter to the update’s configuration:

await Post.update(post.dataValues, {
  where: { id: post.id },
  hooks: true
});

Now that we have the basics of how to define, remove, and execute lifetime events, we can move on to the nuances of utilizing hooks with associations and transactions.

Using lifecycle events with associations and transactions

As the default behavior, Sequelize will execute lifecycle events without associating a transaction with any database queries that are invoked within the lifecycle’s scope. However, sometimes our project requires transactions to be used within lifecycle events, such as an accountant’s ledger or creating log entries. Sequelize offers a transaction parameter when calling certain methods, such as update, create, destroy, and findAll, that will allow us to use a transaction that was defined outside of the lifecycle’s scope to be used within the lifecycle itself.

Note

When calling beforeDestroy and afterDestroy on a model, Sequelize will intentionally skip destroying any associations with that model unless the onDelete parameter is set to CASCADE and the hooks parameter is set to true. This is due to Sequelize needing to explicitly delete each association row by row, which could cause congestion if we are not careful.

If we were to write a naive accounting system and wanted to create logging entries as a separate ledger, we would first define our models like so:

class Account extends Model {}
Account.init({
    name: {
        type: DataTypes.STRING,
        primaryKey: true,
    },
    balance: DataTypes.DECIMAL,
});
class Book extends Model {}
Book.init({
    from: DataTypes.STRING,
    to: DataTypes.STRING,
    amount: DataTypes.DECIMAL,
});

Then, we can add our Ledger model, which is a copy of the Book model with a naive reference column (for brevity) and a signature column, to indicate that the transaction was approved by an external source:

class Ledger extends Model {}
Ledger.init({
    bookId: DataTypes.INTEGER,
    signature: DataTypes.STRING,
    amount: DataTypes.DECIMAL,
    from: DataTypes.STRING,
    to: DataTypes.STRING,
});

To automate the Ledger workflow, we can add an afterCreate hook to our Book model to record the account balance changes:

Book.addHook('afterCreate', async (book, options) => {
    const from = await Account.findOne(book.from);
    const to = await Account.findOne(book.to);
    // pretend that we have an external service that "signs" 
       our transactions
    const signature = await getSignatureFromOracle(book);
    await Ledger.create({
        transactionId: book.id,
        signature: signature,
        amount: book.amount,
        from: from.name,
        to: to.name,
    });
});

Now, when we create a new booking entry, we can pass a transaction reference so that Sequelize can execute queries within the lifecycle scopes under the same transaction. We will be covering transactions more in depth in Chapter 6, Implementing Transactions with Sequelize, but for now, we will give a simple illustrative example of what a transaction would look like:

const Sequelize = require('@sequelize/core');
const sequelize = new Sequelize('db', 'username',  
                                'password');
await sequelize.transaction(async t => {
    // validate our balances here and some other work…
 
    await Book.create({
        to: 'Joe',
        from: 'Bob',
        amount: 20.21,
    }, {
        transaction: t,
    });
   // double check our new balances
   await checkBalances(t, 'Joe', 'Bob', 20.21);
});

The benefit of using a transaction within the lifecycle event is that if any part of the transaction workflow fails to execute, we can halt the rest of the workflow without diluting the quality of our database’s records. Without the transaction parameter being set within the previous example, Sequelize would still have created a Ledger entry, even if the checkBalances method returned an error and did not commit the transaction.

Note

Sequelize will sometimes use its own internal transaction for methods such as findOrCreate. You may always overwrite this parameter with your own transaction.

Now that we have the fundamentals of adding lifecycle events to our models, we can begin updating our Avalon Airlines project.

Putting it all together

For this section, we will only need to update the BoardingTicket model (located in models/boardingticket.js) by adding two attributes, cost and isEmployee, and some lifecycle events for our boarding seat workflow. Let’s look at the steps:

  1. First, we will need to add our attributes within the init method, which should end up looking like this:
      BoardingTicket.init({
        seat: {
          type: DataTypes.STRING,
          validate: {
            notEmpty: {
       msg: 'Please enter in a valid seating arrangement'
            }
          }
        },
        cost: {
          type: DataTypes.DECIMAL(7, 2)
        },
        isEmployee: {
          type: DataTypes.VIRTUAL,
          async get() {
            const customer = await this.getCustomer();
            if (!customer || !customer.email) 
                 return false;
       return customer.email.endsWith('avalonairlines');
          }
        }
      }, {
        sequelize,
        modelName: 'BoardingTicket'
      });
  2. Below the init function, we will want to add our lifecycle events. The first one will check whether the ticket is considered to be an employee ticket and, if so, then mark the subtotal as zero:
      // Employees should be able to fly for free
      BoardingTicket.beforeValidate('checkEmployee', 
                                    (ticket, options) => {
        if (ticket.isEmployee) {
           ticket.subtotal = 0;
        }
      });
  3. Next, we will want to ensure our subtotal is never less than zero (the beforeValidate event would also be applicable here):
      // Subtotal should never be less than zero
      BoardingTicket.beforeSave('checkSubtotal', (ticket, options) => {
        if (ticket.subtotal < 0) {
          throw new Error('Invalid subtotal for this ticket.');
        }
      });
  4. For the last lifecycle event for our model, we will want to check whether the customer had selected a seat that was considered available:
      // Ensure that the seat the customer has requested 
         is available
      BoardingTicket.beforeSave('checkSeat', async (tick
                                 et, options) => {
      // getDataValue will retrieve the new value (as 
         opposed to the previous/current value)
        const newSeat = ticket.getDataValue('seat');
        if (ticket.changed('seat')) {
          const boardingTicketExists = 
          BoardingTick-et.findOne({
            where: { seat: newSeat }
          });
          if (boardingTicketExists) {
            throw new Error(`The seat ${newSeat} has 
            al-ready been taken.`)
          }
        }
      });
  5. After these changes, whenever we create a new boarding ticket, our application will now perform three lifecycle events prior to saving the record. For reference only, the following is an example of how we would pass the transaction to our BoardingTicket model:
    await sequelize.transaction(async t => {
      await BookingTicket.create({
        seat: 'A1',
        cost: 12,
        customerId: 1,
      }, {
        transaction: t,
      });
    });

That wraps up our required changes in this chapter for the Avalon Airlines project. We added a lifecycle event that checks for subtotals and seat availability. We also went through an example of passing a transaction to a specific query, which we will expand upon in the next chapter.

Summary

In this chapter, we went through what a lifecycle event is and how it can be used in day-to-day applications, which lifecycle events are available to Sequelize and in which order they are initiated, and how to add lifecycle events to or remove them from a Sequelize model.

In the next chapter, we will be covering how transactions work, how they are used, and how they can be configured within Sequelize. In addition, the following chapter will also cover different types of locks for transactions and the differences between managed and unmanaged transactions.

References

If you run into issues with lifecycle events, a quick reference can be found here: https://sequelize.org/master/manual/hooks.html.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset