CrackedRuby CrackedRuby

Overview

Data validation verifies that input data meets specified requirements before processing or storage. The validation process examines data against defined rules, constraints, and business logic to ensure correctness, completeness, and safety. Applications perform validation at multiple layers: user input, API requests, database constraints, and inter-service communication.

Validation operates on different data characteristics. Type validation checks whether data matches expected types (string, integer, boolean). Format validation verifies structural patterns like email addresses or phone numbers. Range validation ensures numeric values fall within acceptable bounds. Business rule validation enforces domain-specific constraints that vary by application context.

The validation lifecycle occurs at distinct points in data flow. Client-side validation provides immediate feedback to users but cannot be trusted for security. Server-side validation acts as the authoritative checkpoint, rejecting invalid data before processing. Database-level validation serves as a final safety layer, enforcing constraints through schema definitions.

# Basic validation example
class User
  attr_accessor :email, :age
  
  def valid?
    validate_email && validate_age
  end
  
  private
  
  def validate_email
    email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  end
  
  def validate_age
    age.is_a?(Integer) && age >= 0 && age <= 150
  end
end

user = User.new
user.email = "invalid-email"
user.age = 25
user.valid?  # => false

Validation failures generate error messages that communicate problems to users or calling systems. Error messages balance specificity with security, providing enough information for correction without exposing system internals. Validation frameworks aggregate multiple errors, allowing users to fix all issues simultaneously rather than discovering problems one at a time.

Key Principles

Data validation operates on the principle of fail-fast: detect problems as early as possible in the processing pipeline. Early detection reduces wasted processing on invalid data and prevents corrupted data from propagating through systems. The validation occurs before expensive operations like database writes or external API calls.

Validation separates concerns between syntax and semantics. Syntactic validation checks format and structure without understanding meaning. A credit card number passes syntactic validation if it contains the correct number of digits and passes the Luhn algorithm. Semantic validation verifies the card is valid, not expired, and has sufficient funds for a transaction.

The validation process distinguishes between different error severities. Hard errors indicate data that cannot be processed under any circumstance. A missing required field represents a hard error. Soft errors represent warnings where processing might continue with degraded functionality. A deprecated field format might generate a soft error while still accepting the data.

Validation rules exist in multiple forms with different characteristics. Static rules derive from data types and schema definitions, remaining constant across all records. Dynamic rules depend on runtime context, current system state, or relationships with other data. A date field might require static validation for correct format but dynamic validation ensuring the date falls within an available booking period.

Idempotent validation produces identical results when executed multiple times on the same input. Non-idempotent validation depends on external state that might change between invocations. Checking whether an email address matches a pattern is idempotent. Verifying an email address is not already registered requires a database query and is not idempotent.

Validation frameworks compose rules through logical operators. AND composition requires all rules to pass. OR composition succeeds if any rule passes. XOR composition enforces mutual exclusivity. Complex validation logic combines these operators in nested structures.

# Demonstrating validation composition
class OrderValidator
  def validate(order)
    required_fields(order) &&
      valid_quantities(order) &&
      (guest_checkout?(order) || registered_user?(order))
  end
  
  def required_fields(order)
    order.items.any? && order.total.positive?
  end
  
  def valid_quantities(order)
    order.items.all? { |item| item.quantity > 0 }
  end
  
  def guest_checkout?(order)
    order.email.present? && order.shipping_address.present?
  end
  
  def registered_user?(order)
    order.user_id.present?
  end
end

Validation maintains the single responsibility principle by separating validation logic from business logic. A model class should not contain complex validation code mixed with data manipulation methods. Validator objects encapsulate validation rules, making them reusable and testable independently.

The principle of least surprise guides validation behavior. Validation rules should align with user expectations based on field labels and context. A field labeled "Optional" should not trigger required field errors. Date fields should accept common date formats rather than requiring a single obscure format.

Ruby Implementation

Ruby provides validation through multiple layers: language-level type checking, library-based validation, and framework conventions. Ruby's dynamic typing means validation occurs at runtime rather than compile time, requiring explicit validation code.

ActiveModel provides the standard validation framework used across Ruby applications. Classes include the ActiveModel::Validations module to gain validation functionality. Validators declare rules using class-level macros that generate validation methods.

class Article
  include ActiveModel::Validations
  
  attr_accessor :title, :body, :author, :category, :published_at
  
  validates :title, presence: true, length: { minimum: 5, maximum: 200 }
  validates :body, presence: true, length: { minimum: 100 }
  validates :author, presence: true
  validates :category, inclusion: { in: %w[tech business science] }
  validates :published_at, presence: true, if: :published?
  
  validate :published_date_not_in_future
  
  def published?
    # Custom logic to determine if article is published
    @published == true
  end
  
  private
  
  def published_date_not_in_future
    if published_at && published_at > Time.current
      errors.add(:published_at, "cannot be in the future")
    end
  end
end

article = Article.new
article.title = "Tech"
article.valid?  # => false
article.errors.full_messages
# => ["Title is too short (minimum is 5 characters)",
#     "Body can't be blank",
#     "Author can't be blank"]

Custom validators extend the validation framework for reusable validation logic. Validators inherit from ActiveModel::EachValidator and implement the validate_each method. Custom validators accept options passed in the validation declaration.

class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless value =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
      record.errors.add(attribute, options[:message] || "is not a valid email")
    end
  end
end

class User
  include ActiveModel::Validations
  
  attr_accessor :email, :backup_email
  
  validates :email, presence: true, email: true
  validates :backup_email, email: true, allow_blank: true
end

user = User.new(email: "invalid")
user.valid?  # => false
user.errors[:email]  # => ["is not a valid email"]

Conditional validation uses :if and :unless options to apply rules based on runtime conditions. Conditions can be method names, lambda expressions, or Proc objects. Multiple validators can share the same condition using the with_options method.

class CreditCard
  include ActiveModel::Validations
  
  attr_accessor :number, :cvv, :expiry_month, :expiry_year, :billing_zip
  
  validates :number, presence: true, length: { is: 16 }
  validates :cvv, presence: true, length: { is: 3 }
  validates :expiry_month, inclusion: { in: 1..12 }
  validates :expiry_year, 
    numericality: { greater_than_or_equal_to: Date.current.year }
  
  validates :billing_zip, 
    presence: true,
    if: -> { requires_billing_address? }
  
  validate :card_not_expired
  
  private
  
  def requires_billing_address?
    # Logic to determine if billing address is required
    true
  end
  
  def card_not_expired
    if expiry_year && expiry_month
      expiry = Date.new(expiry_year, expiry_month, -1)
      if expiry < Date.current
        errors.add(:base, "Credit card has expired")
      end
    end
  end
end

Validation contexts allow different rules for different scenarios. The same model might require different validation for creation versus update operations. Contexts are specified when calling valid? or save methods.

class Invoice
  include ActiveModel::Validations
  
  attr_accessor :draft, :number, :date, :total, :approved_by
  
  validates :number, presence: true, on: :publish
  validates :date, presence: true, on: :publish
  validates :total, numericality: { greater_than: 0 }, on: :publish
  validates :approved_by, presence: true, on: :finalize
  
  def publish
    valid?(:publish)
  end
  
  def finalize
    valid?(:finalize)
  end
end

invoice = Invoice.new
invoice.draft = true
invoice.valid?  # => true (no default validations)
invoice.valid?(:publish)  # => false (requires number, date, total)

Validation error objects provide structured access to validation failures. The errors collection stores messages keyed by attribute names. Error messages support internationalization through I18n, allowing translation of error text.

class Product
  include ActiveModel::Validations
  
  attr_accessor :name, :price, :stock
  
  validates :name, presence: true, length: { maximum: 100 }
  validates :price, numericality: { greater_than: 0 }
  validates :stock, numericality: { greater_than_or_equal_to: 0, only_integer: true }
end

product = Product.new
product.name = ""
product.price = -10
product.stock = 5.5

product.valid?  # => false

# Accessing errors
product.errors.count  # => 3
product.errors[:name]  # => ["can't be blank"]
product.errors[:price]  # => ["must be greater than 0"]
product.errors[:stock]  # => ["must be an integer"]

# Full messages
product.errors.full_messages
# => ["Name can't be blank",
#     "Price must be greater than 0",
#     "Stock must be an integer"]

# Checking specific attributes
product.errors.include?(:name)  # => true

Practical Examples

Validating user registration demonstrates multi-field validation with cross-field dependencies. Password confirmation requires comparing two fields. Email uniqueness requires database queries. Terms acceptance represents boolean validation.

class UserRegistration
  include ActiveModel::Validations
  
  attr_accessor :email, :username, :password, :password_confirmation,
                :age, :terms_accepted, :newsletter
  
  validates :email,
    presence: true,
    email: true,
    uniqueness: true
  
  validates :username,
    presence: true,
    length: { minimum: 3, maximum: 20 },
    format: { with: /\A[a-zA-Z0-9_]+\z/ }
  
  validates :password,
    presence: true,
    length: { minimum: 8 },
    format: { 
      with: /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      message: "must include uppercase, lowercase, and digit"
    }
  
  validates :password_confirmation,
    presence: true
  
  validates_confirmation_of :password
  
  validates :age,
    numericality: { only_integer: true, greater_than_or_equal_to: 13 }
  
  validates :terms_accepted,
    acceptance: { message: "must be accepted to continue" }
  
  validate :professional_email_for_minors
  
  private
  
  def professional_email_for_minors
    if age && age < 18
      free_providers = %w[gmail.com yahoo.com hotmail.com]
      domain = email.to_s.split('@').last
      if free_providers.include?(domain)
        errors.add(:email, "must be a school or institutional email for users under 18")
      end
    end
  end
end

registration = UserRegistration.new(
  email: "student@gmail.com",
  username: "jr",
  password: "pass",
  password_confirmation: "pass",
  age: 15,
  terms_accepted: false
)

registration.valid?  # => false
registration.errors.full_messages
# => ["Username is too short (minimum is 3 characters)",
#     "Password is too short (minimum is 8 characters)",
#     "Password must include uppercase, lowercase, and digit",
#     "Terms accepted must be accepted to continue",
#     "Email must be a school or institutional email for users under 18"]

API request validation handles complex nested parameters with varying requirements. Optional parameters require validation only when present. Array parameters need validation for each element. File uploads require size and type constraints.

class ApiRequestValidator
  include ActiveModel::Validations
  
  attr_accessor :endpoint, :method, :headers, :body, :attachments
  
  validates :endpoint, presence: true, format: { with: %r{\A/[a-z0-9/_-]*\z} }
  validates :method, inclusion: { in: %w[GET POST PUT PATCH DELETE] }
  validates :headers, presence: true
  
  validate :content_type_header_present
  validate :body_size_limit
  validate :attachments_valid
  
  MAX_BODY_SIZE = 10.megabytes
  MAX_ATTACHMENT_SIZE = 25.megabytes
  ALLOWED_MIME_TYPES = %w[
    image/jpeg image/png image/gif
    application/pdf application/msword
    application/vnd.openxmlformats-officedocument.wordprocessingml.document
  ].freeze
  
  private
  
  def content_type_header_present
    unless headers.is_a?(Hash) && headers['Content-Type']
      errors.add(:headers, "must include Content-Type")
    end
  end
  
  def body_size_limit
    if body && body.bytesize > MAX_BODY_SIZE
      errors.add(:body, "exceeds maximum size of #{MAX_BODY_SIZE / 1.megabyte}MB")
    end
  end
  
  def attachments_valid
    return unless attachments.is_a?(Array)
    
    attachments.each_with_index do |attachment, index|
      unless attachment.respond_to?(:size) && attachment.respond_to?(:content_type)
        errors.add(:attachments, "attachment #{index} is invalid")
        next
      end
      
      if attachment.size > MAX_ATTACHMENT_SIZE
        errors.add(:attachments, 
          "attachment #{index} exceeds maximum size of #{MAX_ATTACHMENT_SIZE / 1.megabyte}MB")
      end
      
      unless ALLOWED_MIME_TYPES.include?(attachment.content_type)
        errors.add(:attachments,
          "attachment #{index} has unsupported type #{attachment.content_type}")
      end
    end
  end
end

Financial transaction validation enforces business rules around monetary operations. Amount precision matters for currency calculations. Account balance verification prevents overdrafts. Transaction limits prevent fraud. Duplicate detection prevents double-processing.

class TransactionValidator
  include ActiveModel::Validations
  
  attr_accessor :account_id, :amount, :currency, :description,
                :transaction_type, :idempotency_key, :timestamp
  
  validates :account_id, presence: true
  validates :amount, 
    presence: true,
    numericality: { greater_than: 0 }
  validates :currency,
    presence: true,
    inclusion: { in: %w[USD EUR GBP JPY] }
  validates :transaction_type,
    inclusion: { in: %w[deposit withdrawal transfer] }
  validates :idempotency_key,
    presence: true,
    uniqueness: true
  validates :timestamp,
    presence: true
  
  validate :amount_precision
  validate :sufficient_balance, if: -> { transaction_type == 'withdrawal' }
  validate :within_daily_limit
  validate :not_duplicate_transaction
  validate :timestamp_not_future
  
  MAX_DAILY_WITHDRAWAL = 10_000
  
  private
  
  def amount_precision
    if amount
      decimal_places = amount.to_s.split('.').last.length rescue 0
      if decimal_places > 2
        errors.add(:amount, "cannot have more than 2 decimal places")
      end
    end
  end
  
  def sufficient_balance
    account = Account.find(account_id)
    if account.balance < amount
      errors.add(:amount, 
        "insufficient balance (available: #{account.balance} #{currency})")
    end
  rescue ActiveRecord::RecordNotFound
    errors.add(:account_id, "not found")
  end
  
  def within_daily_limit
    if transaction_type == 'withdrawal'
      today_withdrawals = Transaction.where(
        account_id: account_id,
        transaction_type: 'withdrawal',
        created_at: Time.current.beginning_of_day..Time.current.end_of_day
      ).sum(:amount)
      
      if today_withdrawals + amount > MAX_DAILY_WITHDRAWAL
        errors.add(:amount,
          "exceeds daily withdrawal limit (used: #{today_withdrawals}, limit: #{MAX_DAILY_WITHDRAWAL})")
      end
    end
  end
  
  def not_duplicate_transaction
    duplicate = Transaction.find_by(idempotency_key: idempotency_key)
    if duplicate && duplicate.created_at > 1.hour.ago
      errors.add(:idempotency_key, "duplicate transaction detected")
    end
  end
  
  def timestamp_not_future
    if timestamp && timestamp > Time.current
      errors.add(:timestamp, "cannot be in the future")
    end
  end
end

Form input validation handles user-submitted data with specific formatting requirements. Phone numbers accept multiple formats but standardize storage. Date parsing accommodates different input styles. File uploads verify file types through content inspection rather than extension checking.

class ContactFormValidator
  include ActiveModel::Validations
  
  attr_accessor :name, :email, :phone, :message, :preferred_contact_date,
                :contact_method, :attachment
  
  validates :name, presence: true, length: { minimum: 2, maximum: 100 }
  validates :email, presence: true, email: true
  validates :message, presence: true, length: { minimum: 10, maximum: 5000 }
  validates :contact_method, inclusion: { in: %w[email phone either] }
  
  validate :phone_format
  validate :phone_required_for_phone_contact
  validate :preferred_contact_date_format
  validate :attachment_content_type
  
  private
  
  def phone_format
    return if phone.blank?
    
    # Remove common formatting characters
    cleaned = phone.gsub(/[\s\-\(\)\.+]/, '')
    
    unless cleaned =~ /\A\d{10,15}\z/
      errors.add(:phone, "must be a valid phone number")
    end
  end
  
  def phone_required_for_phone_contact
    if contact_method == 'phone' && phone.blank?
      errors.add(:phone, "required when phone contact is selected")
    end
  end
  
  def preferred_contact_date_format
    return if preferred_contact_date.blank?
    
    begin
      date = Date.parse(preferred_contact_date.to_s)
      if date < Date.current
        errors.add(:preferred_contact_date, "must be today or in the future")
      end
    rescue ArgumentError
      errors.add(:preferred_contact_date, "is not a valid date")
    end
  end
  
  def attachment_content_type
    return unless attachment
    
    # Check actual file content, not just extension
    return unless attachment.respond_to?(:read)
    
    file_data = attachment.read(12)
    attachment.rewind
    
    magic_numbers = {
      "\xFF\xD8\xFF" => 'image/jpeg',
      "\x89PNG\r\n\x1A\n" => 'image/png',
      "GIF89a" => 'image/gif',
      "GIF87a" => 'image/gif',
      "%PDF" => 'application/pdf'
    }
    
    detected_type = magic_numbers.find { |magic, _| file_data.start_with?(magic) }&.last
    
    unless detected_type
      errors.add(:attachment, "must be an image or PDF file")
    end
  end
end

Security Implications

Validation functions as the primary defense against injection attacks. SQL injection occurs when unvalidated input becomes part of SQL queries. Command injection happens when user input reaches system shell execution. Validation must sanitize or reject input containing shell metacharacters, SQL operators, or script tags.

class SecureInputValidator
  include ActiveModel::Validations
  
  attr_accessor :search_query, :filename, :command_args
  
  validate :no_sql_injection
  validate :safe_filename
  validate :safe_command_args
  
  SQL_PATTERNS = [
    /(\b(union|select|insert|update|delete|drop|create|alter|exec|execute)\b)/i,
    /(--|\#|\/\*|\*\/)/,  # SQL comment patterns
    /(['";])/             # Quote characters
  ].freeze
  
  SHELL_METACHARACTERS = /[;&|`$<>(){}[\]!]/
  
  private
  
  def no_sql_injection
    return if search_query.blank?
    
    SQL_PATTERNS.each do |pattern|
      if search_query =~ pattern
        errors.add(:search_query, "contains potentially dangerous characters")
        break
      end
    end
  end
  
  def safe_filename
    return if filename.blank?
    
    # Check for path traversal
    if filename.include?('..') || filename.include?('/')
      errors.add(:filename, "cannot contain path components")
    end
    
    # Check for null bytes
    if filename.include?("\x00")
      errors.add(:filename, "cannot contain null bytes")
    end
    
    # Whitelist approach for allowed characters
    unless filename =~ /\A[a-zA-Z0-9_\-\.]+\z/
      errors.add(:filename, "contains invalid characters")
    end
  end
  
  def safe_command_args
    return if command_args.blank?
    
    if command_args.is_a?(String) && command_args =~ SHELL_METACHARACTERS
      errors.add(:command_args, "contains shell metacharacters")
    elsif command_args.is_a?(Array)
      command_args.each do |arg|
        if arg.to_s =~ SHELL_METACHARACTERS
          errors.add(:command_args, "contains shell metacharacters")
          break
        end
      end
    end
  end
end

Cross-site scripting (XSS) prevention requires validating and sanitizing HTML input. User-generated content must not contain malicious JavaScript. Validation can strip HTML tags entirely, whitelist safe tags, or escape special characters. The validation strategy depends on whether HTML formatting is required in the field.

class XssPreventionValidator
  include ActiveModel::Validations
  
  attr_accessor :comment, :bio, :html_content
  
  validate :no_script_tags_in_comment
  validate :safe_html_in_bio
  validate :sanitize_html_content
  
  SCRIPT_PATTERN = /<script\b[^>]*>(.*?)<\/script>/mi
  UNSAFE_PATTERNS = [
    /on\w+\s*=/i,           # Event handlers like onclick=
    /javascript:/i,         # javascript: protocol
    /<iframe/i,             # iframe tags
    /<embed/i,              # embed tags
    /<object/i              # object tags
  ].freeze
  
  ALLOWED_TAGS = %w[p br strong em ul ol li a].freeze
  
  private
  
  def no_script_tags_in_comment
    return if comment.blank?
    
    if comment =~ SCRIPT_PATTERN
      errors.add(:comment, "cannot contain script tags")
    end
    
    UNSAFE_PATTERNS.each do |pattern|
      if comment =~ pattern
        errors.add(:comment, "contains potentially malicious content")
        break
      end
    end
  end
  
  def safe_html_in_bio
    return if bio.blank?
    
    # Extract all HTML tags
    tags = bio.scan(/<(\w+)/).flatten.map(&:downcase)
    
    disallowed = tags - ALLOWED_TAGS
    if disallowed.any?
      errors.add(:bio, "contains disallowed HTML tags: #{disallowed.join(', ')}")
    end
  end
  
  def sanitize_html_content
    return if html_content.blank?
    
    # Use Rails sanitize helper or similar
    sanitized = ActionController::Base.helpers.sanitize(
      html_content,
      tags: ALLOWED_TAGS,
      attributes: %w[href]
    )
    
    if sanitized != html_content
      errors.add(:html_content, "contains unsafe HTML that would be stripped")
    end
  end
end

Mass assignment vulnerabilities occur when validation does not restrict which attributes can be updated. An attacker might add admin=true to a request payload. Strong parameters in Rails provide protection, but validation should verify that sensitive fields cannot be modified through user input.

class MassAssignmentProtection
  include ActiveModel::Validations
  
  attr_accessor :attributes, :current_user, :context
  
  validate :no_protected_attributes
  validate :role_escalation_prevented
  
  PROTECTED_ATTRIBUTES = %w[
    id created_at updated_at
    admin role permissions
    account_balance credit_limit
    password_digest authentication_token
  ].freeze
  
  private
  
  def no_protected_attributes
    return unless attributes.is_a?(Hash)
    
    protected_found = attributes.keys.map(&:to_s) & PROTECTED_ATTRIBUTES
    
    if protected_found.any?
      errors.add(:base, "Cannot modify protected attributes: #{protected_found.join(', ')}")
    end
  end
  
  def role_escalation_prevented
    return unless attributes.is_a?(Hash)
    
    if attributes.key?('role') || attributes.key?(:role)
      unless current_user&.admin?
        errors.add(:role, "cannot be modified without admin privileges")
      end
    end
  end
end

Rate limiting validation prevents abuse by limiting request frequency. Validation checks whether the user or IP address has exceeded allowed request counts within a time window. Failed login attempts require stricter limits than general API requests.

class RateLimitValidator
  include ActiveModel::Validations
  
  attr_accessor :user_id, :ip_address, :action, :timestamp
  
  validate :within_rate_limit
  
  RATE_LIMITS = {
    'api_request' => { count: 1000, window: 1.hour },
    'login_attempt' => { count: 5, window: 15.minutes },
    'password_reset' => { count: 3, window: 1.hour },
    'email_send' => { count: 10, window: 1.hour }
  }.freeze
  
  private
  
  def within_rate_limit
    limit_config = RATE_LIMITS[action]
    return unless limit_config
    
    cache_key = "rate_limit:#{action}:#{identifier}"
    count = Rails.cache.read(cache_key) || 0
    
    if count >= limit_config[:count]
      errors.add(:base, 
        "Rate limit exceeded. Try again in #{time_until_reset(cache_key, limit_config[:window])}")
    end
  end
  
  def identifier
    user_id || ip_address
  end
  
  def time_until_reset(cache_key, window)
    ttl = Rails.cache.fetch("#{cache_key}:ttl") { Time.current + window }
    remaining = ttl - Time.current
    "#{(remaining / 60).ceil} minutes"
  end
end

Common Patterns

The validator object pattern extracts validation logic into dedicated classes. Validator objects separate validation concerns from model classes, improving testability and reusability. Each validator handles a specific validation scenario and can be composed with other validators.

# Validator object pattern
class EmailValidator
  def initialize(email)
    @email = email
  end
  
  def valid?
    format_valid? && deliverable?
  end
  
  def errors
    @errors ||= []
  end
  
  private
  
  def format_valid?
    unless @email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
      errors << "Invalid email format"
      return false
    end
    true
  end
  
  def deliverable?
    domain = @email.split('@').last
    # Check MX records exist for domain
    require 'resolv'
    mx_records = Resolv::DNS.open { |dns| dns.getresources(domain, Resolv::DNS::Resource::IN::MX) }
    
    if mx_records.empty?
      errors << "Email domain has no mail servers"
      return false
    end
    true
  rescue StandardError => e
    errors << "Could not verify email deliverability"
    false
  end
end

# Usage
validator = EmailValidator.new("user@example.com")
if validator.valid?
  # Proceed with email
else
  puts validator.errors
end

The form object pattern encapsulates complex multi-model validation. Forms often span multiple database tables, requiring coordinated validation across related models. Form objects validate the entire operation before persisting any changes.

class OrderForm
  include ActiveModel::Validations
  
  attr_accessor :user_id, :shipping_address_attributes, :billing_address_attributes,
                :payment_method_attributes, :items_attributes
  
  validates :user_id, presence: true
  validates :items_attributes, presence: true
  
  validate :valid_addresses
  validate :valid_payment_method
  validate :valid_items
  validate :inventory_available
  
  def save
    return false unless valid?
    
    ActiveRecord::Base.transaction do
      @order = Order.create!(user_id: user_id)
      create_addresses
      create_payment_method
      create_items
      @order
    end
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end
  
  private
  
  def valid_addresses
    shipping = AddressValidator.new(shipping_address_attributes)
    unless shipping.valid?
      errors.add(:shipping_address, shipping.errors.full_messages.join(', '))
    end
    
    billing = AddressValidator.new(billing_address_attributes)
    unless billing.valid?
      errors.add(:billing_address, billing.errors.full_messages.join(', '))
    end
  end
  
  def valid_payment_method
    validator = PaymentMethodValidator.new(payment_method_attributes)
    unless validator.valid?
      errors.add(:payment_method, validator.errors.full_messages.join(', '))
    end
  end
  
  def valid_items
    return unless items_attributes.is_a?(Array)
    
    items_attributes.each_with_index do |item, index|
      unless item[:product_id] && item[:quantity]
        errors.add(:items, "Item #{index} is incomplete")
      end
    end
  end
  
  def inventory_available
    return unless items_attributes.is_a?(Array)
    
    items_attributes.each do |item|
      product = Product.find(item[:product_id])
      if product.stock < item[:quantity]
        errors.add(:items, 
          "Insufficient stock for #{product.name} (available: #{product.stock})")
      end
    end
  rescue ActiveRecord::RecordNotFound
    errors.add(:items, "Invalid product ID")
  end
  
  def create_addresses
    @order.create_shipping_address!(shipping_address_attributes)
    @order.create_billing_address!(billing_address_attributes)
  end
  
  def create_payment_method
    @order.create_payment_method!(payment_method_attributes)
  end
  
  def create_items
    items_attributes.each do |item|
      @order.items.create!(
        product_id: item[:product_id],
        quantity: item[:quantity],
        price: Product.find(item[:product_id]).price
      )
    end
  end
end

The specification pattern defines business rules as composable objects. Specifications test whether objects satisfy criteria and can be combined using logical operators. This pattern works well for complex conditional validation.

class Specification
  def and(other)
    AndSpecification.new(self, other)
  end
  
  def or(other)
    OrSpecification.new(self, other)
  end
  
  def not
    NotSpecification.new(self)
  end
end

class AndSpecification < Specification
  def initialize(left, right)
    @left = left
    @right = right
  end
  
  def satisfied_by?(candidate)
    @left.satisfied_by?(candidate) && @right.satisfied_by?(candidate)
  end
end

class OrSpecification < Specification
  def initialize(left, right)
    @left = left
    @right = right
  end
  
  def satisfied_by?(candidate)
    @left.satisfied_by?(candidate) || @right.satisfied_by?(candidate)
  end
end

class NotSpecification < Specification
  def initialize(spec)
    @spec = spec
  end
  
  def satisfied_by?(candidate)
    !@spec.satisfied_by?(candidate)
  end
end

# Concrete specifications
class PremiumUserSpec < Specification
  def satisfied_by?(user)
    user.subscription_level == 'premium'
  end
end

class ActiveUserSpec < Specification
  def satisfied_by?(user)
    user.last_login_at > 30.days.ago
  end
end

class VerifiedEmailSpec < Specification
  def satisfied_by?(user)
    user.email_verified_at.present?
  end
end

# Usage
premium_and_active = PremiumUserSpec.new.and(ActiveUserSpec.new)
eligible_for_feature = premium_and_active.and(VerifiedEmailSpec.new)

user = User.find(123)
if eligible_for_feature.satisfied_by?(user)
  # Grant access to feature
end

The null object pattern handles optional validation cleanly. Instead of checking for nil before validation, use null objects that always pass validation. This eliminates conditional logic scattered throughout validation code.

class AddressValidator
  def initialize(address)
    @address = address || NullAddress.new
  end
  
  def valid?
    @address.valid?
  end
  
  def errors
    @address.errors
  end
end

class NullAddress
  def valid?
    true
  end
  
  def errors
    []
  end
  
  def present?
    false
  end
end

# Usage - no nil checks needed
address_validator = AddressValidator.new(user.address)
if address_validator.valid?
  # Proceed
else
  # Handle errors
end

Error Handling & Edge Cases

Validation errors require careful handling to provide useful feedback while protecting system internals. Error messages should identify the problem without exposing database schemas, internal variable names, or implementation details. Generic messages like "Validation failed" provide no actionable information. Specific messages like "Email address is invalid" help users correct problems.

class ValidationErrorHandler
  def self.format_errors(record)
    return {} unless record.respond_to?(:errors)
    
    formatted = {}
    record.errors.each do |error|
      attribute = error.attribute
      message = error.message
      
      # Remove technical prefixes from attribute names
      friendly_attribute = attribute.to_s.humanize
      
      formatted[attribute] ||= []
      formatted[attribute] << "#{friendly_attribute} #{message}"
    end
    
    formatted
  end
  
  def self.api_response(record)
    {
      status: 'error',
      message: 'Validation failed',
      errors: format_errors(record)
    }
  end
  
  def self.log_validation_failure(record, context = {})
    Rails.logger.warn({
      event: 'validation_failure',
      model: record.class.name,
      errors: record.errors.full_messages,
      context: context
    }.to_json)
  end
end

# Usage in API controller
def create
  @user = User.new(user_params)
  
  if @user.valid?
    @user.save!
    render json: @user, status: :created
  else
    ValidationErrorHandler.log_validation_failure(@user, { action: 'create' })
    render json: ValidationErrorHandler.api_response(@user), status: :unprocessable_entity
  end
end

Cascading validation failures occur when one validation failure prevents subsequent validations from running. Required field validation must run before format validation. Type checking must precede range validation. Organizing validations in dependency order prevents confusing error messages.

class Document
  include ActiveModel::Validations
  
  attr_accessor :title, :file_path, :file_size, :created_at
  
  # Validate presence first
  validates :title, presence: true
  validates :file_path, presence: true
  validates :file_size, presence: true
  
  # Then validate format/type
  validates :title, 
    length: { minimum: 3, maximum: 200 },
    if: :title?
  
  validates :file_size,
    numericality: { only_integer: true, greater_than: 0 },
    if: :file_size?
  
  # Finally validate business rules
  validate :file_exists, if: :file_path?
  validate :file_size_reasonable, if: :file_size_valid?
  
  MAX_FILE_SIZE = 100.megabytes
  
  private
  
  def title?
    title.present?
  end
  
  def file_path?
    file_path.present?
  end
  
  def file_size?
    file_size.present?
  end
  
  def file_size_valid?
    file_size.present? && file_size.is_a?(Integer) && file_size > 0
  end
  
  def file_exists
    unless File.exist?(file_path)
      errors.add(:file_path, "file not found at specified path")
    end
  end
  
  def file_size_reasonable
    if file_size > MAX_FILE_SIZE
      errors.add(:file_size, 
        "exceeds maximum allowed size of #{MAX_FILE_SIZE / 1.megabyte}MB")
    end
  end
end

Transient validation failures happen when validation passes initially but fails during processing. A unique constraint might pass validation but fail during database insertion if another process creates a conflicting record. Network timeouts might prevent external validation checks from completing. Validation code must handle these scenarios gracefully.

class UniqueEmailValidator
  def initialize(email, excluding_id: nil)
    @email = email
    @excluding_id = excluding_id
  end
  
  def valid?
    check_uniqueness
  rescue ActiveRecord::StatementInvalid => e
    # Database connection issue
    Rails.logger.error("Database error during email validation: #{e.message}")
    @error = "Unable to verify email uniqueness at this time"
    false
  rescue StandardError => e
    # Unexpected error
    Rails.logger.error("Unexpected error during email validation: #{e.message}")
    @error = "Validation error occurred"
    false
  end
  
  def error
    @error ||= "Email address is already registered"
  end
  
  private
  
  def check_uniqueness
    query = User.where(email: @email)
    query = query.where.not(id: @excluding_id) if @excluding_id
    
    if query.exists?
      @error = "Email address is already registered"
      return false
    end
    
    true
  end
end

# Usage with retry logic
def register_user(email, password)
  retries = 0
  max_retries = 3
  
  begin
    validator = UniqueEmailValidator.new(email)
    
    unless validator.valid?
      return { success: false, error: validator.error }
    end
    
    user = User.create!(email: email, password: password)
    { success: true, user: user }
    
  rescue ActiveRecord::RecordNotUnique
    # Race condition - email was registered between validation and save
    { success: false, error: "Email address is already registered" }
    
  rescue ActiveRecord::StatementInvalid => e
    retries += 1
    if retries < max_retries
      sleep(0.5 * retries)  # Exponential backoff
      retry
    else
      { success: false, error: "Unable to complete registration at this time" }
    end
  end
end

Partial validation allows validating subsets of fields. Wizard-style forms validate different fields at each step. Draft saves validate only completed fields. Context-specific validation runs different rules for different scenarios.

class MultiStepFormValidator
  include ActiveModel::Validations
  
  attr_accessor :step, :name, :email, :address, :payment_method
  
  # Step 1 validations
  validates :name, presence: true, if: -> { step_active?(:personal_info) }
  validates :email, presence: true, email: true, if: -> { step_active?(:personal_info) }
  
  # Step 2 validations
  validates :address, presence: true, if: -> { step_active?(:shipping) }
  validate :valid_address_format, if: -> { step_active?(:shipping) }
  
  # Step 3 validations
  validates :payment_method, presence: true, if: -> { step_active?(:payment) }
  validate :valid_payment_method, if: -> { step_active?(:payment) }
  
  STEPS = [:personal_info, :shipping, :payment]
  
  def validate_step(current_step)
    @step = current_step
    valid?
  end
  
  def all_steps_valid?
    STEPS.all? { |step| validate_step(step) }
  end
  
  private
  
  def step_active?(step_name)
    step == step_name || step == :all
  end
  
  def valid_address_format
    return if address.blank?
    
    required_fields = [:street, :city, :state, :zip]
    missing = required_fields.select { |field| address[field].blank? }
    
    if missing.any?
      errors.add(:address, "missing required fields: #{missing.join(', ')}")
    end
  end
  
  def valid_payment_method
    return if payment_method.blank?
    
    unless %w[credit_card debit_card paypal].include?(payment_method)
      errors.add(:payment_method, "invalid payment type")
    end
  end
end

# Usage in controller
def update_step
  form = MultiStepFormValidator.new(step_params)
  
  if form.validate_step(params[:step].to_sym)
    session[:form_data] = form.attributes
    redirect_to next_step_path
  else
    @errors = form.errors
    render :edit
  end
end

Common Pitfalls

Validating in the wrong layer causes security vulnerabilities and inconsistent behavior. Client-side validation provides user experience benefits but offers no security. Malicious users bypass JavaScript validation by manipulating HTTP requests directly. Server-side validation must repeat all client-side checks.

# WRONG - Trusting client-side validation
class UsersController < ApplicationController
  def create
    # Dangerous - assumes client sent valid data
    @user = User.create!(params[:user])
    render json: @user
  end
end

# CORRECT - Always validate server-side
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    
    if @user.valid?
      @user.save!
      render json: @user, status: :created
    else
      render json: { errors: @user.errors }, status: :unprocessable_entity
    end
  end
  
  private
  
  def user_params
    params.require(:user).permit(:email, :name, :age)
  end
end

Validation without whitelisting allows mass assignment vulnerabilities. Accepting all parameters from requests enables attackers to set any field, including protected attributes like admin flags or account balances.

# WRONG - Accepting all parameters
def create
  @user = User.new(params[:user])  # Dangerous
  @user.save
end

# CORRECT - Whitelist allowed parameters
def create
  @user = User.new(user_params)
  @user.save
end

private

def user_params
  params.require(:user).permit(:email, :name, :age)
end

# For admin-only fields, check authorization
def admin_user_params
  if current_user.admin?
    params.require(:user).permit(:email, :name, :age, :role, :permissions)
  else
    params.require(:user).permit(:email, :name, :age)
  end
end

Regex validation without anchors allows partial matches. A regex /\d{3}-\d{4}/ matches "phone: 555-1234 xxx" because the pattern appears somewhere in the string. Always use \A and \z anchors for complete string matching.

# WRONG - Partial matching
validates :phone, format: { with: /\d{3}-\d{4}/ }
# Accepts: "my phone is 555-1234 and other text"

# CORRECT - Complete string matching
validates :phone, format: { with: /\A\d{3}-\d{4}\z/ }
# Only accepts: "555-1234"

# WRONG - Using ^ and $ which match line boundaries
validates :input, format: { with: /^valid$/}
# Accepts: "valid\nmalicious_code"

# CORRECT - Using \A and \z for string boundaries
validates :input, format: { with: /\Avalid\z/ }
# Only accepts exactly: "valid"

Validation order matters when validations depend on each other. Validating format before presence causes unclear error messages. Validating derived fields before their dependencies produces incorrect results.

# WRONG - Format validation before presence
validates :email, format: { with: /.../ }
validates :email, presence: true
# Error: "Email is invalid" when email is blank

# CORRECT - Presence first, then format
validates :email, presence: true
validates :email, format: { with: /.../ }, if: :email?

# WRONG - Validating calculated field before its dependencies
validates :total_price, numericality: { greater_than: 0 }
validates :quantity, :unit_price, presence: true
# total_price validation runs before dependencies exist

# CORRECT - Validate dependencies first, then calculated fields
validates :quantity, :unit_price, presence: true
validates :total_price, numericality: { greater_than: 0 }, if: :can_calculate_total?

def can_calculate_total?
  quantity.present? && unit_price.present?
end

Ignoring encoding issues in validation causes unexpected failures. String length validation may count bytes instead of characters for multibyte encodings. Regex patterns might fail on non-ASCII input.

# WRONG - Byte length vs character length
validates :name, length: { maximum: 50 }
# "你好世界" is 4 characters but 12 bytes in UTF-8

# CORRECT - Explicitly handle encoding
validates :name, length: { maximum: 50 }
validate :name_character_length

def name_character_length
  if name && name.chars.length > 50
    errors.add(:name, "is too long (maximum is 50 characters)")
  end
end

# For regex validation, use Unicode character classes
validates :username, 
  format: { with: /\A[\p{L}\p{N}_-]+\z/ }  # Matches letters and numbers in any language
# vs /\A[a-zA-Z0-9_-]+\z/ which only matches ASCII

Not handling nil values separately from blank values causes confusion. Empty strings, nil, and false have different meanings but often get conflated in validation.

# WRONG - Treating nil and empty string the same
validates :notes, length: { minimum: 10 }
# Fails on nil with confusing message

# CORRECT - Handle nil explicitly
validates :notes, length: { minimum: 10 }, allow_nil: true
# Or require presence if nil is invalid
validates :notes, presence: true
validates :notes, length: { minimum: 10 }, if: :notes?

# Be careful with boolean values
# WRONG
validates :terms_accepted, presence: true
# Fails when terms_accepted is explicitly false

# CORRECT
validates :terms_accepted, inclusion: { in: [true] }
# Only accepts explicit true value

Database-level validation mismatches cause save failures after successful validation. The model validates successfully but the database rejects the record due to stricter constraints.

# Database has: add_index :users, :email, unique: true
# But model only has:
validates :email, presence: true

# This creates a race condition:
user = User.new(email: "test@example.com")
user.valid?  # => true
user.save    # => raises ActiveRecord::RecordNotUnique

# CORRECT - Match database constraints in model
validates :email, presence: true, uniqueness: true

# For complex database constraints, handle exceptions
def create
  @user = User.new(user_params)
  
  if @user.valid?
    begin
      @user.save!
      render json: @user, status: :created
    rescue ActiveRecord::RecordNotUnique
      @user.errors.add(:email, "has already been taken")
      render json: { errors: @user.errors }, status: :unprocessable_entity
    end
  else
    render json: { errors: @user.errors }, status: :unprocessable_entity
  end
end

Reference

Built-in Validators

Validator Purpose Example
presence Field must not be blank validates :name, presence: true
absence Field must be blank validates :legacy_field, absence: true
length String length constraints validates :title, length: { minimum: 5, maximum: 100 }
numericality Numeric value constraints validates :age, numericality: { only_integer: true, greater_than: 0 }
format Pattern matching with regex validates :email, format: { with: /regexp/ }
inclusion Value must be in list validates :status, inclusion: { in: %w[active inactive] }
exclusion Value must not be in list validates :subdomain, exclusion: { in: %w[www admin] }
uniqueness Value must be unique validates :email, uniqueness: true
confirmation Two fields must match validates :password, confirmation: true
acceptance Boolean must be accepted validates :terms, acceptance: true

Validation Options

Option Purpose Example
allow_nil Skip validation if value is nil validates :notes, length: { minimum: 10 }, allow_nil: true
allow_blank Skip validation if value is blank validates :title, format: { with: /.../ }, allow_blank: true
if Conditional validation with method or lambda validates :phone, presence: true, if: :phone_required?
unless Inverse conditional validation validates :ssn, presence: true, unless: :international?
on Validation context validates :number, presence: true, on: :publish
message Custom error message validates :age, numericality: { greater_than: 0, message: "must be positive" }
strict Raise exception on failure validates :email, presence: true, strict: true

Validation Error Methods

Method Purpose Return Value
valid? Check if model is valid Boolean
invalid? Check if model is invalid Boolean
errors Access errors collection ActiveModel::Errors object
errors.add Add error to attribute Adds error message
errors.count Count of errors Integer
errors.full_messages Array of complete error messages Array of strings
errors.clear Remove all errors Empties errors collection
errors.include? Check if attribute has errors Boolean
errors.messages Hash of errors by attribute Hash with array values

Numericality Options

Option Purpose Example
greater_than Value must exceed number numericality: { greater_than: 0 }
greater_than_or_equal_to Value must be at least number numericality: { greater_than_or_equal_to: 18 }
less_than Value must be below number numericality: { less_than: 100 }
less_than_or_equal_to Value must be at most number numericality: { less_than_or_equal_to: 65 }
equal_to Value must equal number exactly numericality: { equal_to: 42 }
other_than Value must not equal number numericality: { other_than: 0 }
odd Value must be odd numericality: { odd: true }
even Value must be even numericality: { even: true }
only_integer Value must be integer numericality: { only_integer: true }

Length Options

Option Purpose Example
minimum Minimum length length: { minimum: 5 }
maximum Maximum length length: { maximum: 100 }
in Length range length: { in: 5..100 }
is Exact length length: { is: 16 }
within Length range (alias for in) length: { within: 5..100 }

Format Validation Patterns

Pattern Purpose Regex Example
Email address Valid email format /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i
URL Web address format /\Ahttps?://[\S]+\z/
Phone number Numeric phone format /\A\d{10}\z/
Alphanumeric Letters and numbers only /\A[a-zA-Z0-9]+\z/
Slug URL-safe string /\A[a-z0-9-]+\z/
Hex color Hexadecimal color code /\A#([A-Fa-f0-9]{6}
IPv4 address IP address format /\A\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}\z/
Credit card Credit card number /\A\d{13,16}\z/

Custom Validator Template

class CustomValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless condition_met?(value)
      record.errors.add(attribute, 
        options[:message] || "is invalid")
    end
  end
  
  private
  
  def condition_met?(value)
    # Validation logic
    true
  end
end

# Usage
class Model
  include ActiveModel::Validations
  validates :field, custom: true
end

Conditional Validation Template

# Method symbol
validates :field, presence: true, if: :method_name

# Lambda with record parameter
validates :field, presence: true, if: ->(record) { record.condition? }

# Proc with record parameter
validates :field, presence: true, 
  if: Proc.new { |record| record.condition? }

# String evaluation (avoid - security risk)
validates :field, presence: true, if: "condition?"

# Multiple conditions with block
validates :field, presence: true, if: -> { condition1? && condition2? }

Validation Context Usage

# Define context-specific validations
validates :field, presence: true, on: :create
validates :field, presence: true, on: :update
validates :field, presence: true, on: :custom_context

# Validate with context
model.valid?(:custom_context)

# Save with context
model.save(context: :custom_context)

Common Validation Combinations

# Required field with format
validates :email, 
  presence: true,
  format: { with: /regexp/ },
  uniqueness: true

# Numeric field with range
validates :age,
  presence: true,
  numericality: { 
    only_integer: true,
    greater_than_or_equal_to: 0,
    less_than_or_equal_to: 150
  }

# Optional field with conditional constraints
validates :phone,
  format: { with: /regexp/ },
  allow_blank: true,
  if: :phone_provided?

# Complex conditional validation
validates :passport_number,
  presence: true,
  if: -> { international? && !has_visa? }