Skip to content

absaldanha/active_record_constraints

Repository files navigation

ActiveRecordConstraints

Rely on your database constraints to check for data inconsistencies.

Installation

Installation can be done through RubyGems with:

gem install active_record_constraints

or adding it to your Gemfile:

gem "active_record_constraints"

Usage

ActiveRecordConstraints adds 3 new class methods to your ActiveRecord::Base class that can be used to rely on your database constraints to check for data inconsistencies, while you are able to access any errors on your records. You can check for unique constraints, check constraints and foreign key constraints:

Unique constraints

In order to capture and get unique constraints errors, the first step is to define an unique constraint index in a migration:

add_index(:users, :email, unique: true)

With the constraint in place, to capture and get unique constraints errors, use the has_unique_constraint method:

class User < ApplicationRecord  
  has_unique_constraint :email
end

Now, when saving or updating an User record, if the email already exists, the database operation will fail, but the exception will be captured and added as an error to your record:

User.create(email: "foo@mail.com") #=> OK
user = User.create(email: "foo@mail.com") #=> Email already exists
user.errors.messages #=> { email: ["has already been taken"] }

For composite unique indexes, the list of attributes must be used so the index name could be inferred correctly. For example, if user emails are scoped by an account_id:

# Migration
add_index(:users, [:email, :account_id], unique: true)

# Model
class User < ApplicationRecord
  has_unique_constraint [:email, :account_id]
end

# Usage
User.create(email: "bar@mail.com", account_id: 1) #=> OK
user = User.create(email: "bar@mail.com", account_id: 1)
user.errors.messages #=> { email: ["has already been taken"] }

Note that the error is added to the first attribute of the given list of attributes by default. That can be customized with the key parameter of the has_unique_constraint method.

For more complex cases when the name of the index is not the one generated by a Rails migration, the name parameter can be used:

class User < ApplicationRecord
  has_unique_constraint :email, name: "legacy_email_constraint"
end

Error messages can also be customized:

# Model
class User < ApplicationRecord
  has_unique_constraint [:email, :account_id], message: "email already taken for this account"
end

# Usage
user = User.create(email: "foo@mail.com", account_id: 1)
user.errors.messages #=> { email: ["email already taken for this account"] }

Please note that the error is only added after we hit the database, so it will not be visible until all other validations pass. Also note that validating the record will clear the error and it will not be added again until we hit the database again.

Check constraints

In order to capture and get check constraints errors, the first step is to define a check constraint in a migration:

add_check_constraint(:products, "price > 0", name: "price_check")

With the constraint created, to capture and get check constraints errors, use the has_check_constraint method:

class Product < ApplicationRecord
  has_check_constraint :price, name: "price_check"
end

Now, when saving or updating an Product record, if the price is a negative number, the database operation will fail, but the exception will be captured and added as an error to your record:

product = Product.create(price: -10)
product.errors.messages #=> { price: ["is invalid"] }

The error message can also be customized with the message parameter:

# Model
class Product < ApplicationRecord
  has_check_constraint :price, name: "price_check", message: "price can't be negative"
end

# Usage
product = Product.create(price: -10)
product.errors.messages #=> { price: ["price can't be negative"] }

Note that unlike the has_unique_constraint and has_association_constraint methods, the name parameter is required.

Also note that the error is only added after we hit the database, so it will not be visible until all other validations pass. Also note that validating the record will clear the error and it will not be added again until we hit the database again.

Foreign key constraint

In order to capture and get foreign key constraints errors, the first step is to define a foreign key constraint in a migration:

add_foreign_key(:articles, :authors)

With the constraint in place, to capture and get check constraints errors, use the has_association_constraint method:

class Article < ApplicationRecord
  belongs_to :author

  has_association_constraint :author
end

Now, when saving or updating an Article record, if the given author (or author_id) doesn't exist, the database operation will fail, but the exception will be captured and added as an error to your record:

article = Article.create(author_id: 1) #=> assuming an Author with ID 1 doesn't exist
article.errors.messages #=> { author_id: ["must exist"] }

You can also provide the foreign key constraint name with the name parameter:

class Article < ApplicationRecord
  belongs_to :author

  has_association_constraint :author, name: "author_fk"
end

The error message can also be customized with the message parameter:

# Model
class Article < ApplicationRecord
  belongs_to :author

  has_association_constraint :author, message: "that author does not exist"
end

# Usage
article = Article.create(author_id: 1) #=> assuming an Author with ID 1 doesn't exist
article.errors.messages #=> { author_id: ["that author does not exist"] }

Please note that the error is only added after we hit the database, so it will not be visible until all other validations pass. Also note that validating the record will clear the error and it will not be added again until we hit the database again.

Why not validations?

Validations are assumed to be application logic, and should still be used to validate any receiving data for a quick user feedback. However, in some cases, some are unsafe in certain conditions. For example, the validates_uniqueness_of Rails validator executes a query to validate that a there is only one record with a given value across the system, but that is executed in the application layer, and, even if it validates correctly, the operation can still fail (assuming there is an unique index constraint) or the database can be in an invalid state due to race conditions.

Assuming that an unique index constraint exists, the application needs to deal with the exception thrown by the database in order to show a better user feedback. This gem aims to provide this layer between the database and the application, without mixing what is an application validation and what is a database exception handler for certain operations.

Development

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages