Understanding the Challenge
In this lesson, we'll analyze and understand a Sequelize model for a package tracking system. The challenge involves implementing proper validations and constraints on a Package model for a shipping application.
The model needs to enforce rules on various fields such as:
- Tracking numbers must follow specific formatting rules
- Weight must be valid and within acceptable limits
- Recipient information is required and has specific validation rules
- Delivery status must be tracked
- Timestamps for creation and updates should be automatically handled
Let's break down the test specs and the model implementation to understand how these validations work in Sequelize.
Developing a Plan
To understand how the Package model validation works, we'll follow these steps:
- Examine the model and migration files to understand the database structure
- Analyze each test file to understand the validation requirements
- Study the implemented validations in the Package model
- Connect the tests to their corresponding model validations
- Explain how each validation works and why it's important
- Explore potential improvements or alternatives
This analysis will help us understand how Sequelize handles data validation between our application and the database.
Exploring the Solution
Database Structure
First, let's look at the migration file that defines our database table structure:
// YYYYMMDDHHMMSS-create-package.js
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('Packages', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
trackingNumber: {
type: Sequelize.STRING(10), // Enforce 10 character limit
allowNull: false,
unique: true // Each tracking number must be unique
},
weightKg: {
type: Sequelize.DECIMAL(10, 2), // Allow for precise weight values
allowNull: false
},
sender: {
type: Sequelize.STRING(255), // Maximum length of 255
allowNull: true // Sender is optional
},
recipient: {
type: Sequelize.STRING(255), // Maximum length of 255
allowNull: false // Recipient is required
},
isDelivered: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false // Packages start undelivered
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('Packages');
}
};
This migration file sets up our database structure with SQLite constraints like allowNull, unique, and default values. These are enforced at the database level.
Model Validation Implementation
Now, let's examine how the Package model implements validations at the application level:
// package.js
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Package extends Model {
static associate(models) {
// define associations here
}
}
Package.init({
trackingNumber: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
notEmpty: true,
len: [10, 10],
isUppercase: true,
isAlphanumeric: true,
mustHaveNumber(value) {
if (!/[0-9]/.test(value)) {
throw new Error('Tracking number must contain at least one number');
}
}
}
},
weightKg: {
type: DataTypes.DECIMAL,
allowNull: false,
validate: {
notNull: true,
min: 0.01,
max: 500,
rejectStringOne(value) {
if (value === "1") {
throw new Error('Weight must be a decimal number');
}
},
isValidWeight(value) {
if (typeof value === 'string') {
if (!/^\\d*\\.?\\d+$/.test(value)) {
throw new Error('Weight must be a decimal number');
}
} else if (!Number.isFinite(value)) {
throw new Error('Weight must be a decimal number');
}
},
notUndefined(value) {
if (value === undefined || value === null) {
throw new Error('Weight is required');
}
},
}
},
sender: {
type: DataTypes.STRING,
validate: {
len: [2, 255]
}
},
recipient: {
type: DataTypes.STRING,
allowNull: false,
validate: {
notEmpty: true,
len: [5, 255],
notAnonymous(value) {
if (value && value.toLowerCase() === 'anonymous') {
throw new Error('Recipient cannot be Anonymous');
}
}
}
},
isDelivered: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
sequelize,
modelName: 'Package',
});
return Package;
};
This model file contains both database constraints (allowNull, unique) and application-level validations within the validate objects.
Understanding Each Field Validation
trackingNumber Validation
The tracking number field has several validation requirements:
- notEmpty: Must not be an empty string
- len: [10, 10]: Must be exactly 10 characters long
- isUppercase: All letters must be uppercase
- isAlphanumeric: Must contain only letters and numbers (no special characters)
- mustHaveNumber: Custom validation requiring at least one number
- unique: Cannot duplicate existing tracking numbers in the database
- allowNull: false: Cannot be null
The test file (00-trackingNumber-spec.js) verifies all these constraints by testing valid values that should pass and invalid values that should fail.
weightKg Validation
The weight field verifies that:
- notNull: Must have a value
- min: 0.01: Must be at least 0.01 kg (cannot be zero or negative)
- max: 500: Cannot exceed 500 kg
- rejectStringOne: Custom validation to reject the exact string "1" (enforcing decimal numbers)
- isValidWeight: Custom validation to ensure string values are valid decimal numbers
- notUndefined: Custom validation checking for undefined or null values
The test file (01-weightKg-spec.js) tests these validations with various cases.
sender Validation
The sender field is optional but has certain validations:
- len: [2, 255]: If provided, must be between 2 and 255 characters
- allowNull: true: Can be null (not specified in model but implied by absence of allowNull: false)
The test file (02-sender-spec.js) verifies these validations.
recipient Validation
The recipient field has strict requirements:
- notEmpty: Cannot be an empty string
- len: [5, 255]: Must be between 5 and 255 characters
- notAnonymous: Custom validation preventing "anonymous" as a recipient
- allowNull: false: Cannot be null
The test file (03-recipient-spec.js) checks these validations.
isDelivered Validation
The delivery status field:
- type: DataTypes.BOOLEAN: Must be a boolean value
- allowNull: false: Cannot be null
- defaultValue: false: Defaults to not delivered
The test file (04-isDelivered-spec.js) tests these validations.
Timestamp Fields
The createdAt and updatedAt fields are automatically handled by Sequelize, but the migration file specifies they default to CURRENT_TIMESTAMP.
The test files (05-createdAt-spec.js and 06-updatedAt-spec.js) verify these default values work correctly.
How It All Works Together
The Test Environment
The test files use chai and chai-as-promised for testing asynchronous validations. They import utility functions:
resetDB: Resets the database before each testrunValidations: Tests Sequelize validations (application level)runConstraints: Tests SQLite constraints (database level)
Each test follows a similar pattern:
- Reset the database
- Test valid values (should not raise errors)
- Test invalid values (should raise errors)
- Test specific constraints like uniqueness or not null
Validation Layers
Sequelize provides two layers of validation:
- Application-level validation: Defined in the
validateobject of each field in the model. These run before any SQL is generated. - Database-level constraints: Defined both in the model (
allowNull,unique) and the migration file. These are enforced by the database engine.
When you try to save a Package instance:
- Sequelize runs all application validations
- If all pass, it generates and executes SQL
- The database applies its constraints
- If any validation or constraint fails, an error is thrown
Real-World Application
Why Validation Matters
In a real shipping company application, these validations would be critical:
- Tracking number: Must be unique and follow a standard format for customer tracking systems
- Weight: Must be valid for shipping calculations, price determinations, and logistical planning
- Recipient information: Must be complete to ensure delivery is possible
- Delivery status: Critical for operational tracking and customer updates
- Timestamps: Important for tracking SLAs and delivery times
Invalid data could lead to operational issues, customer service problems, or even legal issues if packages are lost or delivered incorrectly.
Multiple Validation Approaches
This example shows several validation techniques used in Sequelize:
- Built-in validators: Like
notEmpty,len,isUppercase - Range validations: Like
minandmaxfor numerical fields - Custom validator functions: Like
mustHaveNumber,notAnonymous - Type validation: Ensuring values are the correct data type
- Database constraints: Like
allowNull: falseandunique: true
In complex applications, you'd typically use a combination of these approaches to ensure data integrity.
Advanced Techniques
Alternative Approaches
There are other ways to enhance our validation system:
1. Using Sequelize Hooks
We could use beforeValidate or beforeCreate hooks for more complex validations:
Package.beforeValidate((package) => {
// Perform complex validation across multiple fields
if (package.isDelivered && !package.deliveryDate) {
throw new Error('Delivery date required for delivered packages');
}
});
2. Creating Validation Modules
For complex applications, you might extract validations to separate modules:
// validations/tracking-number.js
module.exports = {
isValidTrackingFormat(value) {
// Complex validation logic
return /[A-Z0-9]{10}/.test(value) && /[0-9]/.test(value);
}
};
// In model
const trackingValidations = require('../validations/tracking-number');
// ...
validate: {
isValidFormat(value) {
if (!trackingValidations.isValidTrackingFormat(value)) {
throw new Error('Invalid tracking number format');
}
}
}
3. Implementing Custom Error Messages
Sequelize allows you to customize error messages for better user feedback:
validate: {
notEmpty: {
msg: 'Tracking number cannot be empty'
},
len: {
args: [10, 10],
msg: 'Tracking number must be exactly 10 characters'
}
}
Recap and Key Takeaways
Understanding Sequelize Validation
Sequelize provides a powerful validation system with:
- Built-in validators for common patterns
- Custom validator functions for complex rules
- Database constraints for data integrity
- Hooks for advanced validation workflows
Best Practices
When implementing validations:
- Validate at both application and database levels
- Write thorough tests for each validation rule
- Use custom validators for business-specific rules
- Provide helpful error messages for users
- Consider validation in the context of your application's needs
Further Learning
To deepen your understanding of Sequelize validations:
- Study the Sequelize documentation on validations
- Explore different validator patterns for various use cases
- Learn about validation libraries like Joi or Yup that can be integrated with Sequelize
- Practice implementing validators for different business scenarios
Practice Exercise
Try implementing a model for a User with the following validations:
- Username: Required, 3-20 characters, alphanumeric with underscores only, unique
- Email: Required, valid email format, unique
- Password: Required, minimum 8 characters, must contain at least one uppercase letter, one lowercase letter, and one number
- Age: Optional, but if provided must be between 13 and 120
- Role: Required, must be one of: "user", "moderator", "admin"
Write tests similar to the ones we've seen to verify your validations work correctly.