Rails validation và multi-pages form

Rails validation thông thường

Hãy bắt đầu với một ví dụ về validation:

Class Person < ActiveRecord::ApplicationRecord
  validates :name, date_of_birth, ni_number, presence: true
end

Đoạn code trên giúp bạn xác thực dữ liệu, bằng cách gọi đến phương thức valid? hoặc khi thêm mới, lưu hay sửa một bản ghi trong database. Nếu một trong các trường name, date_of_birth hay ni_number là nil hay chuỗi rỗng, thì phương thức valid? sẽ trả về false, hoặc là trong trường hợp tạo mới, lưu, sửa một bản ghi thì bản ghi đó sẽ không được lưu vào database, và trả về false. Nếu bạn sử dụng save!, create! hay update!, expection ActiveRecord::RecordInvalid sẽ được ném ra.

Các options để thêm điều kiện cho validations

Một trường hợp được đặt ra là nếu bạn sử dụng form GDS (Government Digital Services), là form mà mỗi câu hỏi sẽ nằm ở 1 trang, ví dụ như này, có thể sử dụng validation của Rails được không?

Câu trả lời là có, và có thể sử dụng một số cách sau:

Dùng session để lưu dữ liệu

Trong quá trình bạn chuyển giữa các trang, dữ liệu sẽ được lưu vào session, và chỉ validation và lưu vào database khi tất cả thông tin đã được điền xong. Tuy nhiên cách này có 1 số nhược điểm:

  • Chỉ validation khi đã kết thúc quá trình, vì vậy bạn phải chuyển lại đúng trang bị lỗi validation để hiển thị thông báo lỗi.
  • Sẽ rất khó nếu muốn điền vài trang, lưu lại rồi lúc khác quay lại điền tiếp.

Dùng phương thức validation bình thường, nhưng bổ sung thêm điều kiện:

Ví dụ

Class Person < ActiveRecord::ApplicationRecord
  validates :name, presence: true
  validates :date_of_birth, presence: true, if: create_stage > 1
  validates :ni_number, presence: true, if: create_stage > 2
end

Để làm được điều này thì cần set biến create_stage để biết đang ở trang nào. Nó thích hợp cho một model và form đơn giản, nhưng nó sẽ nhanh chóng trở nên khó kiểm sóat.

Sử dụng class riêng cho validation

Cách này khá hay khi validation phức tạp và có điều kiện. Chúng ta sẽ bê toàn bộ phần code validation sang 1 class khác, cách này sẽ giúp model bớt cồng kềnh. Phần tiếp theo sẽ nói rõ hơn về cách này.

Class riêng cho validation

Đầu tiên, ta thêm 1 cột vào bảng cần xác thực dữ liệu, trong trường hợp này là bảng Person, để biết đang xác thực dữ liệu ở trang nào:

rails generate migration AddCreateStageToPerson create_stage:integer

Sau đó chạy rails db:migrate để thêm những thay đổi vào database. Tiếp theo, chúng ta bỏ dòng validates trong model, thay bằng validates_with với class validation.

class Person < ApplicationRecord
  validates_with PersonValidator
end

Ta sẽ đặt file chứa class validation này vào thư mục /app/validators. Bất kì file nào để trong thư mục /app đều được tự động load vào Rails, vì vậy chúng ta không cần làm gì đặc biệt để nó load class này. Class validation phải kế thừa ActiveModel::Validator, và phải có phương thức validate với param truyền vào là record. Đây là nơi đặt code xác thực dữ liệu.

Class validation sẽ trông giống thế này:

class PersonValidator < ActiveModel::Validator
  def validate(record)
    @record = record
    __send__("validate_stage_#{@record.create_stage}")
  end

  private
  def validate_stage_1
    validate_present(:name)
  end

  def validate_stage_2
    validate_stage_1
    validate_present(:date_of_birth)
  end

  def validate_stage_3
    validate_stage_2
    validate_present(:ni_number)
  end

  def validate_present(attr)
    unless @record.__send__(attr).present?
      @record.errors.add(attr, "Can't be blank")
    end
  end
end

Giải thích code

Mỗi lần phương thức valid? được gọi trong bản ghi person, phương thức validate của class PersonValidator được gọi đến, với biến truyền vào là bản ghi person. Trong phương thức validate, chúng ta coi bản ghi person là một biến thực thể để truyền vào các phương thức khác thông qua câu lệnh __send__(“validate_stage_#{@record.create_stage}”)

__send__ hiểu đơn giản là gọi đến phương thức được truyền vào. Trong trường hợp này, nếu thuộc tính create_stage của record là 2 thì sẽ gọi đến phương thức validate_stage_2.

Phương thức validate_present được gọi đến để thêm thông báo lỗi với biến truyền vào là tên thuộc tính, ví dụ attr truyền vào là :name thì unless @record.__send__(attr).present? sẽ hiểu là unless @record.name.present?

Nếu sau khi chạy xong phương thức PersonValidator#validate mà không có thông báo lỗi nào trong error hash, Rails sẽ coi bản ghi đó là hợp lệ.

Kết luận

Những điều này có vẻ rườm rà hơn nhiều so với việc thêm 1 dòng validate vào model, nhưng đối với những validation phức tạp, nó có 1 số lợi thế sau:

  • Nó tổng hợp tất cả validation của model. Chúng ta luôn cố gắng để đơn giản hóa Controller, chuyển sang Model. Tuy nhiên không nên chỉ chuyển hết tất cả vào model, ở những ứng dụng phức tạp, model sẽ trở nên khổng lồ. Đây là 1 cách để tránh việc model có đến hằng trăm dòng code, lâu lâu đọc lại là đau đầu phết đấy.
  • Validation rất dễ để viết test. Chỉ cần truyền đối tượng Person với các thuộc tính và kì vọng nó sẽ trả ra đúng thông báo lỗi, kiểu như vậy:
context 'stage 2' do
  let(:person)    { Person.new(create_stage: 2) }

  context 'both name and date of birth supplied' do
    it 'is valid' do
      person.name = 'John Smith'
      person.date_of_birth = Date.new(1990, 5, 12)
      expect(person).to be_valid
    end
  end

  context 'name and date of birth not supplied' do
    it 'is not valid' do
      expect(person).not_to be_valid
      expect(person.errors[:name]).to eq ["Can't be blank"]
      expect(person.errors[:date_of_birth]).to eq ["Can't be blank"]
      expect(person.errors[:ni_number]).to be_empty
    end
  end
  • Quy ước thế nào là hợp lệ có thể dễ dàng thay đổi và test độc lập. Rõ ràng, ví dụ đưa ra trong bài viết này rất đơn giản, nên việc tách class có vẻ hơi thừa thãi, nhưng với những validation phức tạp, thì việc tách nó ra class khác và test độc lập sẽ giúp model gọn gàng và dễ hiểu hơn rất nhiều.

Nguồn: https://medium.com/just-tech/rails-validations-and-multi-page-forms-c30e9d066baf