Tìm hiểu về Active Record Validations

1. Validations là gì?

1.1 Tại sao dùng validations?

Validations là cách để đảm bảo dữ liệu của bạn hợp lệ trước khi lưu vào database. Có nhiều cách để thực hiện validate:

  • DB constraints: Khiến cho cơ sở dữ liệu trở lên độc lập và khó để test hoặc bảo trì. Ví dụ trong Rails tuts có đoạn này:
add_index :users, :email , unique: true
  • Client-side validations: Dễ bị bỏ qua. Ví dụ nếu sử dụng JS để validations, nếu trên browser mà JS bị tắt, thì validations sẽ bị bỏ qua. Tuy nhiên nếu kết hợp được với các kỹ thuật khác, đây sẽ là 1 cách rất hay để validations và trả lại feedback ngay lập tức cho user.
  • Controller-level validations: Rất khó để test validations ở level này. Ngoài ra, nó sẽ làm controller bị béo (nhiều code). Controller nên gầy 1 tý để ứng dụng có thể chạy trong thời gian dài.

Chốt lại, team Rails bảo là model-level validations là dễ dùng nhất.

1.2 When Does Validation Happen?

Có 2 loại ActiveRecord object: loại liên quan và loại không liên quan đến một record trong cơ sở dữ liệu.

Loại không liên quan được tạo bằng method new. Ta sử dụng method new_record? để phân biệt 2 loại object này.

$ a = User.new
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
$ a.new_record?
=> true
$ User.first.new_record?
=> false

Validations ở model level luôn chỉ chạy khi quá trình create hoặc update 1 bản ghi thông qua ActiveRecord được diễn ra. Khi khởi tạo bản ghi thì ActiveRecord sẽ gửi 1 câu INSERT còn khi cập nhật thì nó gửi 1 câuUPDATE query vào cơ sở dữ liệu. Validations luôn chạy trước khi ActiveRecord định thực hiện câu INSERT hoặc UPDATE vào cơ sở dữ liệu.

Các method dưới đây sẽ gọi vào validations:

create
create!
save
save!
update
update!

1.3 Skipping validations

Các method sau sẽ skip validations:

decrement!
decrement_counter
increment!
increment_counter
toggle!
touch
update_all
update_attribute
update_column
update_columns
update_counters

Hoặc có thể dùng save(validate: false) để skip validations

a = User.new name: ""
=> #<User id: nil, name: "", location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
irb(main):006:0> a.save
   (0.1ms)  begin transaction
   (0.1ms)  rollback transaction
=> false
irb(main):007:0> a.save(validate: false)
   (0.1ms)  begin transaction
  User Create (0.7ms)  INSERT INTO "users" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", ""], ["created_at", "2019-09-12 07:42:37.363766"], ["updated_at", "2019-09-12 07:42:37.363766"]]
   (91.0ms)  commit transaction
=> true

1.4 valid? , invalid?

Trước khi 1 ActiveRecord object được save, Rails sẽ chạy validations. Nếu validations có lỗi, Rails sẽ không save object lại. Các lỗi của object được lưu bởi collection .errors.messages.

> a = User.create name: ""
   (0.1ms)  begin transaction
   (0.1ms)  rollback transaction
=> #<User id: nil, name: "", location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
> a.errors.messages
=> {:name=>["can't be blank"]}
  • Sau khi chạy validations, nếu a.errors.messages rỗng thì a là 1 valid object .

  • Các object tạo từ method .new không bao giờ trả về errors, vì nó không chạy validations.

> a = User.new 
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
> a 
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
> a.errors.messages
=> {}
  • valid? method sẽ triggers validations và trả về giá trị false nếu errors.messages rỗng.

> a = User.new 
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
> a 
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
> a.errors.messages
=> {}

> a.valid?
=> false
> a.errors.messages
=> {:name=>["can't be blank"]}

invalid? chỉ đơn giản là method đảo của valid? . Nó cũng triggers validations với object và trả về giá trị boolean ngược lại với valid?

1.5 errors

Để xác định xem 1 attribute nhất định của object có valid không, ta làm kiểm tra bằng cú pháp object.errors[:attributes].any? :

a = User.create(name: "").errors[:name].any?
=> false

Cú pháp này chỉ sử dụng được sau khi quá trình validations được diễn ra.

1.6 errors.details

Lấy ra symbol của validator:

>> person = Person.new
>> person.valid?
>> person.errors.details[:name] # => [{error: :blank}]

2. Validation helper

  • ActiveRecord cung cấp nhiều validation helpers. Các helper này xây dựng dựa trên nhiều nguyên tắc validations phổ biến.
  • Nếu validation fail, 1 message sẽ được add vào object.errors và messages này liên kết với attribute đã được validate.
  • Mỗi validation helper có thể truyền vào nhiều attributes.
class User < ApplicationRecord
  validates :name, :location, presence: true
end
  • Mỗi validation helper đều có 1 default message. Option :message sẽ có ích khi bạn không muốn dùng default message :
class User < ApplicationRecord
  validates :name, :location, presence: { message: "attribute này không được trống!" }
end

a = User.new 
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>
> a.valid?
=> false
> a.errors.messages
=> {:name=>["attribute này không được trống!"], :location=>["attribute này không được trống!"]}

Bây giờ, chúng ta cùng tìm hiểu 1 số validations helper phổ biến.

2.1 acceptance

Ta thường sử dụng acceptance kết hợp với thẻ <input type="checkbox"> . Giả sử ta có 1 cái form yêu cầu nhập fields is_teen như sau:

#app/users/edit.html.erb
<%= form_for @user do |f| %>
  <%= f.check_box is_teen %>
  <%= f.submit "Submit" %>
<% end %>
#app/controllers/users_controller.rb
class UsersController < ApplicationController
  def new
    @user = User.new params[:user]
  end

  def create
    @user = User.new user_params
    if @user.save
      redirect_to root_path
    else
      flash[:danger] = @user.errors.messages
      redirect_to root_path
    end
  end

  private
  def user_params
    params.require(:user).permit :is_teen
  end
end

Ta thêm helper acceptance vào model User

class User < ApplicationRecord
  validates :is_teen, acceptance: true
end

Khi checkbox không được tích vào thì user_params sẽ có giá trị thế này:

(byebug) user_params
<ActionController::Parameters {"is_teen"=>"0"} permitted: true>

Và quá trình validations diễn ra như thế này:

> @user = User.new user_params
=> #<User id: nil, name: nil, location: nil, is_teen: false, image_path: nil, created_at: nil, updated_at: nil, male: false>

>  @user.valid? 
=> false

> @user.errors.full_messages
=> ["Is_teen must be accepted"]

2.2 inclusion, exclusion

Helper inclusion dùng để kiểm tra attribute nhập vào có nằm trong 1 tập hợp các giá trị cho trước không.

Cùng xem ví dụ dưới đây để hiểu:

class User < ApplicationRecord
  validates :name, inclusion: { in: ["Hiep", "Hieu"] }
end
> user = User.new name: "Hung" 
=> #<User id: nil, name: "Hung", location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil, male: nil>
> user.valid?
=> false
> user.errors.full_messages
=> ["Name is not included in the list"]

Ngược lại với inclusion, chúng ta helper exclusion . Nếu attribute nhập vào không nằm trong tập các giá trị cho trước thì được coi là valid .

class User < ApplicationRecord
  validates :name, exclusion: { in: ["Hiep", "Hieu"] }
end
> user = User.new name: "Hieu" 
=> #<User id: nil, name: "Hieu", location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil, male: nil>
> user.valid?
=> false
> user.errors.full_messages
=> ["Name is reserved"]

2.2 presence, absence

Helper presence dùng để kiểm tra xem attribute có phải 1 blank hay không, thông qua method blank?. Nếu attribute.blank? trả về true thì sau khi validations sẽ thực hiện rollback.

Đầu tiên, bạn cần biết khi nào thì method blank? trả về true.

> "".blank?
=> true

> "  ".blank?
=> true

> " \t  \n".blank?
=> true

> nil.blank?
=> true

>  false.blank
=> true

Thử với ví dụ dưới đây:

class User < ApplicationRecord
  validates :name, presence: true
end
> user = User.new name: "\t \n"
=> #<User id: nil, name: "\t \n", location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil, male: nil>
> user.valid?
=> false
> user.errors.full_messages
=> ["Name can't be blank"]

presence nghĩa là sự có mặt, nên thường chúng ta sẽ nghĩ helper này dùng để kiểm tra sự có mặt của attribute .Tuy nhiên với boolean value thì không hẳn thế .

false.blank? cũng trả về true nên để validates sự có mặt attribute theo kiểu boolean, chúng ta nên dùng inclusion:

class User < ApplicationRecord
  validates :is_teen, inclusion: { in: [true, false] }
end
> user = User.new is_teen: false
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil, is_teen: false>
> user.valid?
=> true

Ngược lại với helper presence, helper absence dùng method present? kiểm tra xem attribute truyền vào có rỗng hay không. Nếu attribute.present? trả về true thì sau validation sẽ thực hiện rollback.

> nil.present?
=> false

> "".present?
=> false

> "  ".present?
=> false

> false.present?
=> false

2.4 numericality

Helper này dùng để đảm bảo các thuộc tính phải là kiểu Numeric . Nếu attribute không phải là kiểu Numeric, sau khi validations sẽ thực hiện rollback. Default message của helper này là [is not a number]

class User < ApplicationRecord
  validates :age, numericality: true
end
> user = User.new age: "ahihihi"
=> #<User id: nil, name: nil, location: nil, age: 0, image_path: nil, created_at: nil, updated_at: nil, male: nil>
> user.valid?
=> false
> user.errors.full_messages
=> ["Age is not a number"]

Có khá nhiều tùy chọn với helper này. Nếu bạn muốn attribute được validate chỉ thuộc kiểu integer, chúng ta có option only_integer: true

class User < ApplicationRecord
  validates :age, numericality: { only_integer: true }
end
> user = User.new age: 1.5
=> #<User id: nil, name: nil, location: nil, age: 1, image_path: nil, created_at: nil, updated_at: nil, male: nil>
> user.valid?
=> false
> user.errors.full_messages
=> ["Age must be an integer"]

Ngoài ra chúng ta còn có một loạt các tùy chọn khác tương ứng với các symbol sau:

  • :greater_than : đảm bảo attribute phải lớn hơn 1 giá trị mà bạn muốn.
  • :greater_than_or_equal_to : đảm bảo attribute phải lớn hơn hoặc bằng 1 giá trị mà bạn muốn.
  • :equal_to : đảm bảo attribute phải bằng một giá trị mà bạn muốn.
  • :odd : đảm bảo attribute phải là một số lẻ.
  • :even : đảm bảo attribute phải là một số chẵn.

2.5 uniqueness

uniqueness hoạt động như nào?

class User < ApplicationRecord
  validates :name, uniqueness: true
end

thêm option case_sensitive.

validates :name, uniqueness: { case_sensitive: false }

Với bài toán, 1 người không được follow người khác 2 lần.

class Relationship < ApplicationRecord
  ?????
end

Còn rất nhiều validation helper khác rất hữu dụng, các bạn có thể tìm đọc thêm tại đây.

3. Một số option phổ biến trong các helper

3.1 allow_nil

Bạn muốn :name không thể là rỗng, nhưng chấp nhận giá trị nil.

validates :name, presence: true, allow_nil: true

3.2 allow_blank

3.3 :on

validates :age, numericality: { greater_than: 16 }, on: :update

4. Custom validation

Nếu những validations helper mà ActiveRecord tạo sẵn cho bạn vẫn không thể giải quyết được bài toán mà bạn đang gặp phải, thì bạn có thể tự validation theo cách của mình . Mình tìm thấy 2 cách có thể thực hiện custom validation: custom methodcustom validator

4.1 Custom method

Ví dụ, mình có bài toán follow rất quen thuộc với 2 bảng UserRelationship như sau.

class User < ApplicationRecord
  has_many :active_relationships, class_name: Relationship.name,
    foreign_key: :follower_id
  has_many :passive_relationships, class_name: Relationship.name,
    foreign_key: :followed_id
  has_many :following, through: :active_relationships,
    source: :followed
  has_many :followers, through: :passive_relationships,
    source: :follower
 end
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: User.name
  belongs_to :followed, class_name: User.name
end

Bây giờ mình muốn validate trường hợp, user không thể tự follow chính mình. Nghĩa là khi 1 bản ghi Relationship được tạo, nếu follower_id == followed_id trả về true, thì sau quá trình validation phải thực hiện rollback .

Bài toán này khá khó để dùng các validation helper sẵn có của ActiveRecord, vì vậy mình sẽ thực hiện custom validation như sau .

class Relationship < ApplicationRecord
  validate :cannot_follow_yourself
  belongs_to :follower, class_name: User.name
  belongs_to :followed, class_name: User.name
  
  private
    def cannot_follow_yourself
      if followed_id == follower_id
        errors.add(:you, Settings.relationship.cannot_follow_yourself )
      end
    end
end

Thực hiện validate:

> relationship = Relationship.new follower_id: 1, followed_id: 1
=> #<Relationship id: nil, follower_id: 1, followed_id: 1, created_at: nil, updated_at: nil>

> relationship.valid?
  User Load (15.3ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  User Load (0.3ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
=> false

> relationship.errors.full_messages
=> ["You can't follow yourself"]

Phương pháp thực hiện validate nói trên được gọi là custom validation sử dụng custom method.

3.2 Custom validator.

Giả sử trong rails app của bạn có các model Course, PlanEvent . Cả 3 bảng này đều có 2 trường start_dateend_date . Bạn cần validates ở cả 3 model, để đảm bảo rằng start_date luôn phải nhỏ hơn hoặc bằng end_date . Sẽ có 2 vấn đề ở bài toán này:

  • Một là, khó để tìm ra 1 validation helper có sẵn mà phù hợp với yêu cầu của bài toán, vậy nên chúng ta sẽ sử dụng custom validation.
  • Hai là, nếu sử dụng custom methodchúng ta sẽ phải viết lại method đấy 3 lần ở mỗi model. Như thế khá khó để sử dụng lại code .

Để giải quyết cả 2 vấn đề nói trên, mình sẽ viết 1 class riêng chứa validator, và gọi lại class này ở mỗi model cần validate.

Đầu tiên mình tạo một thư lục để lưu các class validators và config để rails có thể load được nó trong file application.rb

#config/application.rb
config.load_defaults 5.2
config.autoload_paths += %W["#{config.root}/app/validators/"]
#app/validators/date_validator.rb
class DateValidator < ActiveModel::Validator
  def validate(record)
    if record.start_date > record.end_date
      record.errors[:start_date] << "Start date cannot greater than end date""
    end
  end
end

Giờ mình sẽ gọi validator này với method validates_with

class Course < ApplicationRecord
  include ActiveModel::Validations
  validates_with DateValidator
end

class Plan < ApplicationRecord
  include ActiveModel::Validations
  validates_with DateValidator
end

class Event < ApplicationRecord
  include ActiveModel::Validations
  validates_with DateValidator
end

Thử chạy validate nhé:

> course = Course.new start_date: Date.new(2019) , end_date: Date.new(2018)
=> #<Course id: nil, content: nil, start_date: "2019-01-01", end_date: "2018-01-01">
> course.valid?
=> false
> course.errors.full_messages
=> ["Start date cannot greater than end date"]
> plan = Plan.new start_date: Date.new(2019) , end_date: Date.new(2018)
=> #<Plan id: nil, content: nil, start_date: "2019-01-01", end_date: "2018-01-01">
> plan.valid?
=> false
> plan.errors.full_messages
=> ["Start date cannot greater than end date"]

Đó là tất cả những gì mình muốn trình bày trong bài viết lần này.


References:

https://guides.rubyonrails.org/active_record_validations.html