{"title":"Validations","description":"Data validation and error handling in Grant ORM","section":"guides/models/grant","version":"v2","path":"guides/models/grant/validations","canonical_url":"https://amberframework.org/docs/v2/guides/models/grant/validations","markdown_url":"https://amberframework.org/docs/v2/guides/models/grant/validations.md","inherited":false,"content_markdown":"# Validations\n\n> **Supported web path:** Amber CLI `2.0.6` includes Grant in every generated\n> web application and pins the reviewed V2 commit. Preserve that pin while\n> following this beta.\n\n## Where the examples go\n\nValidation declarations, custom validator methods, conditions, and validation\ncallbacks belong inside the matching Grant model under `src/models/`. Examples\nthat call validation methods or inspect errors run from the controller,\nservice, form object, or spec that owns the operation. Database constraints\nbelong in the migration system selected by the application. Blocks on this page\nuse those destinations unless a closer comment identifies a different role.\n\nGrant runs model validations before persistence and records failures on the\nmodel's error collection.\n\n## Basic Validation\n\n```crystal\nclass User < Grant::Base\n  column email : String\n  column age : Int32\n\n  validates_email :email\n  validates_numericality_of :age, greater_than: 0\nend\n\nuser = User.new(email: \"invalid\", age: -5)\nuser.valid?  # => false\nuser.errors  # => Array of validation errors\nuser.save    # => false (won't save invalid records)\nuser.save!   # => raises Grant::RecordInvalid\n```\n\n## Built-in Validators\n\n### Presence and Absence\n\n```crystal\nclass Product < Grant::Base\n  column name : String\n  column internal_notes : String?\n\n  validates_presence_of :name\n  validate_not_blank :name\n\n  validates_absence_of :internal_notes  # Must be nil/blank\nend\n```\n\n### Numericality\n\n```crystal\nclass Order < Grant::Base\n  column total : Float64\n  column quantity : Int32\n  column discount : Float64\n\n  validates_numericality_of :total, greater_than: 0\n  validates_numericality_of :quantity,\n    only_integer: true,\n    greater_than: 0\n  validates_numericality_of :discount,\n    greater_than_or_equal_to: 0,\n    less_than_or_equal_to: 100\nend\n```\n\n**Options:**\n- `greater_than`, `greater_than_or_equal_to`\n- `less_than`, `less_than_or_equal_to`\n- `equal_to`, `other_than`\n- `odd: true`, `even: true`\n- `only_integer: true`\n- `in: range`\n- `allow_nil: true`, `allow_blank: true`\n\n### Format\n\n```crystal\nclass User < Grant::Base\n  column username : String\n  column phone : String\n\n  validates_format_of :username, with: /\\A[a-zA-Z0-9_]+\\z/\n  validates_format_of :phone, with: /\\A\\d{3}-\\d{3}-\\d{4}\\z/\n  validates_format_of :username, without: /\\A(admin|root)\\z/,\n    message: \"is reserved\"\nend\n```\n\n### Length/Size\n\n```crystal\nclass Article < Grant::Base\n  column title : String\n  column body : String\n  column tags : Array(String)\n\n  validates_length_of :title, minimum: 5, maximum: 100\n  validates_length_of :body, minimum: 100\n  validates_size_of :tags, maximum: 10\n  validates_length_of :slug, is: 8  # Exactly 8\nend\n```\n\n### Email and URL\n\n```crystal\nclass Contact < Grant::Base\n  column email : String\n  column website : String?\n\n  validates_email :email\n  validates_url :website, allow_blank: true\nend\n```\n\n### Confirmation\n\n```crystal\nclass Account < Grant::Base\n  column email : String\n  column password : String\n\n  validates_confirmation_of :email\n  validates_confirmation_of :password\nend\n\n# Usage requires confirmation fields\naccount = Account.new(\n  email: \"user@example.com\",\n  password: \"secret123\"\n)\naccount.email_confirmation = \"user@example.com\"\naccount.password_confirmation = \"secret123\"\naccount.valid?  # => true\n```\n\n### Inclusion and Exclusion\n\n```crystal\nclass Subscription < Grant::Base\n  column plan : String\n  column username : String\n\n  validates_inclusion_of :plan,\n    in: [\"free\", \"basic\", \"premium\", \"enterprise\"]\n\n  validates_exclusion_of :username,\n    in: [\"admin\", \"root\", \"system\"],\n    message: \"is reserved\"\nend\n```\n\n### Uniqueness\n\n```crystal\nclass User < Grant::Base\n  column email : String\n  column employee_id : String\n  column company_id : Int64\n\n  validate_uniqueness :email\n\n  # Scoped uniqueness (unique within scope)\n  validate_uniqueness :employee_id, scope: :company_id\nend\n```\n\n## Custom Validations\n\n### Block Syntax\n\n```crystal\nclass Post < Grant::Base\n  column title : String\n  column content : String\n\n  validate :title, \"can't be blank\" do |post|\n    !post.title.to_s.blank?\n  end\n\n  validate :content, \"must be at least 10 characters\" do |post|\n    post.content.size >= 10\n  end\nend\n```\n\n### Method Reference\n\n```crystal\nclass Product < Grant::Base\n  column price : Float64\n  column sale_price : Float64?\n  column on_sale : Bool\n\n  validate :valid_sale_price\n\n  private def valid_sale_price\n    return true unless on_sale && sale_price\n\n    if sale_price.not_nil! >= price\n      errors.add(:sale_price, \"must be less than regular price\")\n    end\n  end\nend\n```\n\n### Model-level Validation\n\n```crystal\nclass Order < Grant::Base\n  validate \"total must equal sum of line items\" do |order|\n    calculated_total = order.line_items.sum(&.total_price)\n    (order.total_amount - calculated_total).abs < 0.01\n  end\nend\n```\n\n## Conditional Validations\n\n### Using Symbols\n\n```crystal\nclass Post < Grant::Base\n  column title : String\n  column content : String\n  column published : Bool\n\n  validates_length_of :title, minimum: 10, if: :published?\n  validates_presence_of :content, unless: :draft?\n\n  def published?\n    published == true\n  end\n\n  def draft?\n    !published\n  end\nend\n```\n\n### Using Procs\n\n```crystal\nclass Order < Grant::Base\n  column payment_method : String\n  column credit_card : String?\n\n  validates_presence_of :credit_card,\n    if: ->(order : Order) { order.payment_method == \"credit\" }\nend\n```\n\n## Validation Contexts\n\n```crystal\nclass User < Grant::Base\n  column email : String\n  column password : String\n\n  # Only on create\n  validates_presence_of :password, on: :create\n\n  # Only on update\n  validates_confirmation_of :password, on: :update\n\n  # Custom context\n  validate :email, \"must be corporate email\", on: :corporate do |user|\n    user.email.ends_with?(\"@company.com\")\n  end\nend\n\n# Usage with context\nuser.valid?(:corporate)\nuser.save(context: :corporate)\n```\n\n## Working with Errors\n\n```crystal\nuser = User.new(email: \"invalid\", age: 10)\nuser.valid?  # => false\n\n# Get all errors\nuser.errors  # => Array(Grant::Error)\n\n# Get errors for specific field\nemail_errors = user.errors.select { |e| e.field == :email }\n\n# Get error messages\nuser.errors.map(&.message)\n# => [\"is not a valid email\", \"must be at least 18\"]\n\n# Full error messages\nuser.errors.map { |e| \"#{e.field} #{e.message}\" }\n# => [\"email is not a valid email\", \"age must be at least 18\"]\n\n# Add custom errors\nuser.errors.add(:base, \"Something went wrong\")\n```\n\n## Custom Error Messages\n\n```crystal\nclass User < Grant::Base\n  validates_numericality_of :age,\n    greater_than_or_equal_to: 18,\n    message: \"You must be at least 18 years old\"\n\n  validates_format_of :email,\n    with: /@company\\.com\\z/,\n    message: \"must be a company email address\"\nend\n```\n\n## Validation Callbacks\n\n```crystal\nclass User < Grant::Base\n  before_validation :normalize_email\n  after_validation :set_defaults\n\n  private def normalize_email\n    self.email = email.downcase.strip if email\n  end\n\n  private def set_defaults\n    self.role ||= \"user\" if errors.empty?\n  end\nend\n```\n\n## Skipping Validations\n\n```crystal\n# Skip validations (use carefully!)\nuser.save(validate: false)\n\n# Bulk operations skip validations\nUser.update_all(active: false)\n```\n\n## Best Practices\n\n### 1. Layer Validations\n\n```crystal\nclass CreditCard < Grant::Base\n  # Format validation\n  validates_format_of :number, with: /\\A\\d{16}\\z/\n\n  # Business logic validation\n  validate :number, \"must pass Luhn check\" do |card|\n    LuhnValidator.valid?(card.number)\n  end\n\n  # Database constraint (in migration)\n  # ADD CONSTRAINT valid_card_number CHECK (char_length(number) = 16)\nend\n```\n\n### 2. Add Database Constraints\n\n```crystal\n# Model validation\nvalidate_uniqueness :email\n\n# Also add database constraint\n# CREATE UNIQUE INDEX users_email_unique ON users(email);\n```\n\n### 3. Order Validations by Cost\n\n```crystal\nclass Product < Grant::Base\n  # Fast validations first\n  validates_presence_of :name\n  validates_length_of :name, in: 1..100\n\n  # Database queries later\n  validate_uniqueness :sku\n\n  # Expensive operations last\n  validate :image, \"must be valid\" do |product|\n    ImageValidator.valid?(product.image_data) if product.image_data\n  end\nend\n```"}