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_toless_than,less_than_or_equal_toequal_to,other_thanodd: true,even: trueonly_integer: truein: rangeallow_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