Database Constraints and Model Validations in Sequelize

Understanding Data Integrity Protection

Protecting data integrity in your application is like building a house with multiple security systems. Database constraints are like the structural security features built into the house itself (like reinforced doors and windows), while model validations are like the security system that checks visitors before they even reach the door.

Database-Level Constraints vs Model-Level Validations

Let's explore these two protection mechanisms through a practical example of an e-commerce system's product catalog:

Database-Level Constraints (In Migration)


// migrations/YYYYMMDDHHMMSS-create-products.js
module.exports = {
    up: async (queryInterface, Sequelize) => {
        await queryInterface.createTable('Products', {
            id: {
                type: Sequelize.INTEGER,
                primaryKey: true,
                autoIncrement: true
            },
            name: {
                type: Sequelize.STRING(100),
                allowNull: false,    // Structural constraint
                unique: true         // Ensures product names are unique
            },
            price: {
                type: Sequelize.DECIMAL(10, 2),
                allowNull: false,
                validate: {
                    min: 0.01       // Database-enforced minimum price
                }
            },
            categoryId: {
                type: Sequelize.INTEGER,
                references: {        // Foreign key constraint
                    model: 'Categories',
                    key: 'id'
                },
                onDelete: 'SET NULL' // Referential integrity action
            }
        });

        // Creating a composite unique constraint
        await queryInterface.addConstraint('Products', {
            fields: ['name', 'categoryId'],
            type: 'unique',
            name: 'unique_product_per_category'
        });
    },
    down: async (queryInterface, Sequelize) => {
        await queryInterface.dropTable('Products');
    }
};
                

Model-Level Validations


// models/product.js
module.exports = (sequelize, DataTypes) => {
    const Product = sequelize.define('Product', {
        name: {
            type: DataTypes.STRING(100),
            validate: {
                notEmpty: {
                    msg: "Product name cannot be empty"
                },
                len: {
                    args: [3, 100],
                    msg: "Product name must be between 3 and 100 characters"
                }
            }
        },
        price: {
            type: DataTypes.DECIMAL(10, 2),
            validate: {
                isDecimal: {
                    msg: "Price must be a valid decimal number"
                },
                isPositive(value) {
                    if (parseFloat(value) <= 0) {
                        throw new Error('Price must be greater than zero');
                    }
                }
            }
        }
    });
    return Product;
};
                

When to Use Each Approach

Choosing between database constraints and model validations is like choosing between different types of security measures. Let's understand when to use each:

Use Database Constraints When:

Database constraints are like the foundation of your house - they're essential for structural integrity. Use them for:

Data Integrity: When you need to guarantee data consistency at the database level, like ensuring a user's email is unique across the entire system. For example:


// In migration
email: {
    type: Sequelize.STRING,
    allowNull: false,
    unique: true
}
                    

Referential Integrity: When managing relationships between tables, like ensuring every order belongs to a valid user:


// In migration
userId: {
    type: Sequelize.INTEGER,
    references: {
        model: 'Users',
        key: 'id'
    },
    onDelete: 'CASCADE'
}
                    

Performance-Critical Rules: When the validation needs to be enforced efficiently at the database level, like preventing duplicate records:


// In migration
await queryInterface.addConstraint('Orders', {
    fields: ['orderNumber'],
    type: 'unique',
    name: 'unique_order_number'
});
                    

Use Model Validations When:

Model validations are like your application's front desk - they handle checks before data reaches the database. Use them for:

Business Logic: When implementing complex business rules that go beyond simple data constraints:


// In model
price: {
    type: DataTypes.DECIMAL,
    validate: {
        isPriceValid(value) {
            if (this.category === 'premium' && value < 100) {
                throw new Error('Premium products must cost at least $100');
            }
        }
    }
}
                    

User Experience: When you need to provide immediate feedback to users:


// In model
username: {
    type: DataTypes.STRING,
    validate: {
        len: {
            args: [3, 20],
            msg: 'Username must be between 3 and 20 characters'
        },
        isAlphanumeric: {
            msg: 'Username can only contain letters and numbers'
        }
    }
}
                    

Complex Validations: When you need to perform validations that involve multiple fields or external services:


// In model
validate: {
    async isPromotionValid() {
        if (this.promoCode && !(await validatePromoCode(this.promoCode))) {
            throw new Error('Invalid promotion code');
        }
    }
}
                    

Real-World Example: User Registration System

Let's look at a complete example that combines both approaches in a user registration system:


// migrations/YYYYMMDDHHMMSS-create-users.js
module.exports = {
    up: async (queryInterface, Sequelize) => {
        await queryInterface.createTable('Users', {
            id: {
                type: Sequelize.INTEGER,
                primaryKey: true,
                autoIncrement: true
            },
            email: {
                type: Sequelize.STRING,
                allowNull: false,
                unique: true        // Database constraint
            },
            username: {
                type: Sequelize.STRING,
                allowNull: false,
                unique: true        // Database constraint
            },
            passwordHash: {
                type: Sequelize.STRING,
                allowNull: false
            },
            status: {
                type: Sequelize.ENUM('active', 'inactive', 'suspended'),
                defaultValue: 'active'
            },
            createdAt: {
                type: Sequelize.DATE,
                allowNull: false
            },
            updatedAt: {
                type: Sequelize.DATE,
                allowNull: false
            }
        });

        // Add composite unique constraint
        await queryInterface.addConstraint('Users', {
            fields: ['email', 'status'],
            type: 'unique',
            name: 'unique_active_email'
        });
    },
    down: async (queryInterface, Sequelize) => {
        await queryInterface.dropTable('Users');
    }
};

// models/user.js
module.exports = (sequelize, DataTypes) => {
    const User = sequelize.define('User', {
        email: {
            type: DataTypes.STRING,
            validate: {
                isEmail: {
                    msg: 'Please provide a valid email address'
                },
                async isUnique(value) {
                    const existingUser = await User.findOne({ where: { email: value } });
                    if (existingUser && existingUser.id !== this.id) {
                        throw new Error('Email address is already in use');
                    }
                }
            }
        },
        username: {
            type: DataTypes.STRING,
            validate: {
                len: {
                    args: [3, 30],
                    msg: 'Username must be between 3 and 30 characters'
                },
                isAlphanumeric: {
                    msg: 'Username can only contain letters and numbers'
                }
            }
        },
        passwordHash: {
            type: DataTypes.STRING,
            validate: {
                notEmpty: true
            }
        }
    }, {
        hooks: {
            beforeValidate: async (user) => {
                // Additional validation logic
                if (user.isNewRecord && !user.password) {
                    throw new Error('Password is required');
                }
            }
        }
    });

    return User;
};
                

Best Practices for Data Protection

When implementing data protection in your application, consider these guidelines:

Layer Your Protection: Use both database constraints and model validations together, like having both a lock on your door and a security system. Database constraints provide the last line of defense, while model validations offer better user experience.

Performance Considerations: Remember that database constraints are enforced by the database engine and are typically more efficient than model validations. Use them for critical data integrity checks.

Error Handling: Implement proper error handling for both constraint violations and validation failures. Provide clear, user-friendly error messages that help users understand and correct their input.

Testing: Always test both your constraints and validations thoroughly. Create test cases that attempt to violate each rule to ensure your protection mechanisms work as expected.