Chia sẻ một số validator hữu dụng trong Rails

Validate là một bước hết sức quan trọng để kiểm tra tính hợp lệ của dữ liệu trước khi được lưu xuống database. Có rất nhiều cách để validate dữ liệu trước khi nó được lưu vào database của bạn, bao gồm cả việc ràng buộc dữ liệu ở model, validate ở phía client và validate ở tầng controller. Đối với Rails, Rails tổ chức việc validate dữ liệu ở tầng model để đảm bảo tính hiệu quả trong hầu hết các trường hợp. Rails cung cấp cho người dùng một loạt các phương thức (validation helpers), bạn có thể sử dụng trực tiếp các phương thức này ngay trong model. Dù phong phú là vậy nhưng trong một số trường hợp nhất định hoặc khi làm việc với một số khách hàng "kỳ quái" với những yêu cầu oái ăm, sẽ có những trường hợp validate mà Rails chưa hỗ trợ. Những lúc đó hoặc là tìm đến các gem hoặc xui hơn là bạn phải tự định nghĩa một validator chuyên biệt cho trường hợp của mình.

Bài viết sẽ không đề cập đến phần cơ bản về validations trong Ruby on Rails mà sẽ xem như bạn đọc đã nắm vững được cách thức hoạt động cơ bản của model validator trong Rails. Mình sẽ giới thiệu qua một số validator mà mình đã viết và hướng dẫn cách sử dụng chúng như thế nào.

1. DateValidator - Validate ngày giờ

Giả sử mình có một model Event, trong đó có 2 trường thời gian bắt đầu (start_time) và thời gian kết thúc (end_time), cả 2 đều có kiểu dữ liệu là datetime. Quá dễ hiểu là end_time phải nằm sau (hoặc bằng :LOL) start_time. Trong trường hợp này cần 1 validator về thời gian, something like this:

validates :end_time, date: {after: :start_time}
# or
validates :start_time, date: {before_or_equal_to: :end_time}

Trên ý tưởng đó, mình sẽ xây dựng một validator để xác thực end_time phải nằm sau start_time như sau:

# app/validators/date_validator.rb
class DateValidator < ActiveModel::EachValidator
  def validate_each record, attribute, value
    return if value.blank?
    criteria = options.keys.first
    comparation_field = parse_date record, options
    return unless comparation_field[:value]
    case criteria
    when :after
      unless value > record.try(comparation_field[:value])
        message = options[:message] || :after
        record.errors.add attribute, message, date: comparation_field[:field_name]
      end
    when :after_or_equal_to
      unless value >= record.try(comparation_field[:value])
        message = options[:message] || :after_or_equal_to
        record.errors.add attribute, message, date: comparation_field[:field_name]
      end
    end

    protected
    def parse_date record, options
      comparation_field = options.values.first
      comparation_field = begin
        {
          value: comparation_field.to_datetime,
          field_name: comparation_field 
        }
      rescue NoMethodError, ArgumentError
        {
          value: record.try comparation_field,
          field_name: record.class.human_attribute_name comparation_field
        }
      end
    end
  end
end

Tương tự cho validate beforebefore_or_equal_to các bạn có thể tự viết thêm 2 case đó vào.

Sử dụng validator như sau:

class Event < ApplicationRecord
  validates :end_time, date: {after: :start_time}
  # Hoặc:
  validates :end_time, date: {after: "30/04/2017"}
  # Hoặc nếu muốn override error message, chỉ cần truyền thêm option[:message]
  validates :end_time, date: {after: "30/04/2017", message: " phải nằm sau 30/04/2017"}
end
message = options[:message] || :after
message = options[:message] || :after_or_equal_to

Nếu có ai thắc mắc 2 dòng này là gì thì mình sẽ giải thích như sau: afterafter_or_equal_to là 2 key được định nghĩa trong file locale chứa message lỗi mặc định nếu người dùng không override message option (Cái này tự định nghĩa trước chứ i18n không có sẵn đâu nhá).

en:
  errors:
    messages:
      after:  " must be after %{date}."
      after_or_equal_to: " must be after or equal to %{date}."

Như vậy trong 3 trường hợp ví dụ nêu trên, 3 message lỗi tương ứng được bắn ra khi dữ liệu không hợp lệ sẽ là:

-  Start time must be after End time.
-  Start time must be after or equal to 30/04/2017.
-  Start time phải nằm sau 30/04/2017.

2. PlainTextLengthValidator - Validate độ dài string trong đoạn mã HTML

Đây là một tình huống phổ biến nếu như trong project của bạn có sử dụng một text editor, chuỗi mà bạn sẽ lưu vào DB là một chuỗi lẫn lộn với các thẻ HTML. Do đó nếu dùng LengthValidator sẽ không hoạt động được. Trong trường hợp này, ý tưởng là bạn sẽ phải đọc text này và chỉ lấy phần text bên trong các thẻ HTML. May mắn cho chúng ta là gem Nokogiri làm việc này. Để cài đặt gem bạn vui lòng ghé github của gem và làm theo hướng dẫn.

Phần code sẽ như bên dưới:

# app/validators/plain_text_validator.rb
class PlainTextLengthValidator < ActiveModel::EachValidator
  def validate_each record, attribute, value
    return if value.blank?
    message = options[:message]
    html_text = record.try(attribute)&.gsub /[\n\t\r]/, ""
    txt_length = Nokogiri::HTML(html_text).text.size
    message = options[:message]

    case true
    when options[:maximum].present? && txt_length > options[:maximum]
      message ||= :too_long
      record.errors.add attribute, message, count: options[:maximum]
    when options[:minimum].present? && txt_length < options[:minimum]
      message ||= :too_short
      record.errors.add attribute, message, count: options[:minimum]
    when options[:within].present? && !txt_length.between? options[:within].first, options[:within].last
      message ||= :invalid_length
      record.errors.add attribute, message, min: options[:within].first, max: options[:within].last
    end
  end
end

# Nhớ định nghĩa :invalid_length trong file ngôn ngữ của i18n, đường dẫn tương tự phần 1.

Ví dụ sử dụng với model Article, có trường content chứa nội dung của bài viết

class Article < ActiveRecord
  validates :content, plain_text_length: {maximum: 1000}
  # Hoặc
  validates :content, plain_text_length: {within: 300..1000, message: :wrong_length}
end

Messageslỗi tương ứng:

-  Content must be 1000 charactorsor less.
-  Content phải nằm trong khoảng 300 - 1000 ký tự.

Trong đó file ngôn ngữ của i18n như sau:

en:
  errors:
    messages:
      too_long:  " must be %{count} charactorsor less."
      too_short: " must be  %{count} or more."
      invalid_length: " must be within %{min} to %{max} charactors."
      wrong_length: "  phải nằm trong khoảng %{min} - %{max} ký tự."

3. ImageValidator - Validate kích thước file ảnh

Thật ra đây là cái validator thừa thải nhất mình từng viết ra. Thực tế sử dụng gem carrierwavemini_magick hoàn toàn có thể xử lý resize ảnh trước khi lưu, thì người dùng thoải mái upload ảnh mọi kích thước đều có thể đưa về một size tối đa.

Tuy nhiên khách hàng là thượng đế, thích thì chiều thôi 😄

# app/validators/image_validator.rb
class ImageValidator < ActiveModel::EachValidator
  def validate_each record, attribute, value
    return unless record.send("#{attribute}_changed?") || record.send("#{attribute}?")
    max_width = options[:max_width]
    max_height = options[:max_height]
    message = options[:message]
    begin
      image = MiniMagick::Image.open record.image.path
      if image[:width] > max_width || image[:height] > max_height
        message ||= :invalid_image_size
        record.errors.add attribute, message, size: "#{max_width}x#{max_height}"
      end
    rescue
    end
  end
end

Để sử dụng được validator này cần phải cài gem MiniMagick, hướng dẫn cài đã có sẵn tại github của gem, mình không hướng dẫn lại. Sử dụng:

class User < ActiveRecord
  validates :avatar, image: {max_width: 1024, max_height: 768}
end

Kết luận

Trên đây là một số validator được mình viết ra trong suốt quá trình làm dự án. Có thể còn nhiều thiếu sót hoặc nhiều chỗ cần được cải thiện thêm, mong nhận được góp ý từ các bạn đọc bằng cách để lại comment phía dưới bài viết.