1.4.1

Validations

Preview ecosystem guide: Grant is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.

Grant provides a robust validation system that ensures data integrity before persisting to the database.

Basic Validation

class User < Grant::Base
  column email : String
  column age : Int32

  validates_email :email
  validates_numericality_of :age, greater_than: 0
end

user = User.new(email: "invalid", age: -5)
user.valid?  # => false
user.errors  # => Array of validation errors
user.save    # => false (won't save invalid records)
user.save!   # => raises Grant::RecordInvalid

Built-in Validators

Presence and Absence

class Product < Grant::Base
  column name : String
  column internal_notes : String?

  validates_presence_of :name
  validate_not_blank :name

  validates_absence_of :internal_notes  # Must be nil/blank
end

Numericality

class Order < Grant::Base
  column total : Float64
  column quantity : Int32
  column discount : Float64

  validates_numericality_of :total, greater_than: 0
  validates_numericality_of :quantity,
    only_integer: true,
    greater_than: 0
  validates_numericality_of :discount,
    greater_than_or_equal_to: 0,
    less_than_or_equal_to: 100
end

Options:

  • greater_than, greater_than_or_equal_to
  • less_than, less_than_or_equal_to
  • equal_to, other_than
  • odd: true, even: true
  • only_integer: true
  • in: range
  • allow_nil: true, allow_blank: true

Format

class User < Grant::Base
  column username : String
  column phone : String

  validates_format_of :username, with: /\A[a-zA-Z0-9_]+\z/
  validates_format_of :phone, with: /\A\d{3}-\d{3}-\d{4}\z/
  validates_format_of :username, without: /\A(admin|root)\z/,
    message: "is reserved"
end

Length/Size

class Article < Grant::Base
  column title : String
  column body : String
  column tags : Array(String)

  validates_length_of :title, minimum: 5, maximum: 100
  validates_length_of :body, minimum: 100
  validates_size_of :tags, maximum: 10
  validates_length_of :slug, is: 8  # Exactly 8
end

Email and URL

class Contact < Grant::Base
  column email : String
  column website : String?

  validates_email :email
  validates_url :website, allow_blank: true
end

Confirmation

class Account < Grant::Base
  column email : String
  column password : String

  validates_confirmation_of :email
  validates_confirmation_of :password
end

# Usage requires confirmation fields
account = Account.new(
  email: "[email protected]",
  password: "secret123"
)
account.email_confirmation = "[email protected]"
account.password_confirmation = "secret123"
account.valid?  # => true

Inclusion and Exclusion

class Subscription < Grant::Base
  column plan : String
  column username : String

  validates_inclusion_of :plan,
    in: ["free", "basic", "premium", "enterprise"]

  validates_exclusion_of :username,
    in: ["admin", "root", "system"],
    message: "is reserved"
end

Uniqueness

class User < Grant::Base
  column email : String
  column employee_id : String
  column company_id : Int64

  validate_uniqueness :email

  # Scoped uniqueness (unique within scope)
  validate_uniqueness :employee_id, scope: :company_id
end

Custom Validations

Block Syntax

class Post < Grant::Base
  column title : String
  column content : String

  validate :title, "can't be blank" do |post|
    !post.title.to_s.blank?
  end

  validate :content, "must be at least 10 characters" do |post|
    post.content.size >= 10
  end
end

Method Reference

class Product < Grant::Base
  column price : Float64
  column sale_price : Float64?
  column on_sale : Bool

  validate :valid_sale_price

  private def valid_sale_price
    return true unless on_sale && sale_price

    if sale_price.not_nil! >= price
      errors.add(:sale_price, "must be less than regular price")
    end
  end
end

Model-level Validation

class Order < Grant::Base
  validate "total must equal sum of line items" do |order|
    calculated_total = order.line_items.sum(&.total_price)
    (order.total_amount - calculated_total).abs < 0.01
  end
end

Conditional Validations

Using Symbols

class Post < Grant::Base
  column title : String
  column content : String
  column published : Bool

  validates_length_of :title, minimum: 10, if: :published?
  validates_presence_of :content, unless: :draft?

  def published?
    published == true
  end

  def draft?
    !published
  end
end

Using Procs

class Order < Grant::Base
  column payment_method : String
  column credit_card : String?

  validates_presence_of :credit_card,
    if: ->(order : Order) { order.payment_method == "credit" }
end

Validation Contexts

class User < Grant::Base
  column email : String
  column password : String

  # Only on create
  validates_presence_of :password, on: :create

  # Only on update
  validates_confirmation_of :password, on: :update

  # Custom context
  validate :email, "must be corporate email", on: :corporate do |user|
    user.email.ends_with?("@company.com")
  end
end

# Usage with context
user.valid?(:corporate)
user.save(context: :corporate)

Working with Errors

user = User.new(email: "invalid", age: 10)
user.valid?  # => false

# Get all errors
user.errors  # => Array(Grant::Error)

# Get errors for specific field
email_errors = user.errors.select { |e| e.field == :email }

# Get error messages
user.errors.map(&.message)
# => ["is not a valid email", "must be at least 18"]

# Full error messages
user.errors.map { |e| "#{e.field} #{e.message}" }
# => ["email is not a valid email", "age must be at least 18"]

# Add custom errors
user.errors.add(:base, "Something went wrong")

Custom Error Messages

class User < Grant::Base
  validates_numericality_of :age,
    greater_than_or_equal_to: 18,
    message: "You must be at least 18 years old"

  validates_format_of :email,
    with: /@company\.com\z/,
    message: "must be a company email address"
end

Validation Callbacks

class User < Grant::Base
  before_validation :normalize_email
  after_validation :set_defaults

  private def normalize_email
    self.email = email.downcase.strip if email
  end

  private def set_defaults
    self.role ||= "user" if errors.empty?
  end
end

Skipping Validations

# Skip validations (use carefully!)
user.save(validate: false)

# Bulk operations skip validations
User.update_all(active: false)

Best Practices

1. Layer Validations

class CreditCard < Grant::Base
  # Format validation
  validates_format_of :number, with: /\A\d{16}\z/

  # Business logic validation
  validate :number, "must pass Luhn check" do |card|
    LuhnValidator.valid?(card.number)
  end

  # Database constraint (in migration)
  # ADD CONSTRAINT valid_card_number CHECK (char_length(number) = 16)
end

2. Add Database Constraints

# Model validation
validate_uniqueness :email

# Also add database constraint
# CREATE UNIQUE INDEX users_email_unique ON users(email);

3. Order Validations by Cost

class Product < Grant::Base
  # Fast validations first
  validates_presence_of :name
  validates_length_of :name, in: 1..100

  # Database queries later
  validate_uniqueness :sku

  # Expensive operations last
  validate :image, "must be valid" do |product|
    ImageValidator.valid?(product.image_data) if product.image_data
  end
end