Forms and Validation
When a request brings user input, Snails::Form projects it onto declared fields, runs your rules in declaration order, and collects the errors in that order. The controller writes the row only when valid? passes; the 422 body is the ordered message list.
Declare a form#
A form declares its fields once, then adds rules to them. The scaffold's PostForm checks the posts input before the controller writes it:
class PostForm < Snails::Form
FIELDS = %w[title body].each { |n| define_method(n) { read_attribute(n) } }
validates :title, presence: true
validates :title, length: { minimum: 3, maximum: 120 }, allow_blank: true
validates :body, presence: true
validate :title_cannot_contain_html
def title_cannot_contain_html
errors.add(:title, "must not contain HTML") if title.to_s.include?("<")
end
endFIELDS is the list input is projected onto. A key outside it never reaches a rule; a missing field reads nil. The form accepts a Hash and raises ArgumentError for anything else. write_attribute is the only writer; there are no generated setters. normalize runs once after projection when you need to trim or canonicalize input:
def normalize
write_attribute(:title, title.to_s.strip)
endvalid? clears the error list, runs the rules in declaration order, and answers true when none failed. Calling it again starts fresh.
The rules#
| Rule | Options | What it checks |
|---|---|---|
presence: true |
— | the value is not nil and not blank |
length |
minimum:, maximum:, is:, within: Range |
string size (Array/Hash size for collections) |
format |
with: Regexp or without: Regexp |
the whole string matches, or does not match |
inclusion |
in: collection |
the value is in the collection |
exclusion |
in: collection |
the value is not in the collection |
uniqueness |
model:, column:, scope: |
no other row holds this value |
confirmation: true |
— | the *_confirmation sibling matches |
validate :method |
if:, unless: |
your own rule, reported with errors.add |
length takes minimum:/maximum:/is: or one within: Range, not both. is: is exact. inclusion and exclusion compare with the collection's own include?; an Array value asks every element, which is the collection-field case. uniqueness reads model.find_by with the column (the attribute name by default) and any scope: fields; it does not write a lock, so a concurrent insert can still win the race and surface as Sqlx::RecordNotUnique.
confirmation: true belongs on the field being confirmed, not on the confirmation field. For validates :password, confirmation: true, the form must also declare password_confirmation; the rule compares password with that sibling.
A custom rule is an instance method that calls errors.add; a name that does not exist raises on the first valid? instead of skipping silently.
Options that soften a rule#
allow_blank: true skips a rule when the value is blank; allow_nil: true skips it when the value is nil. if: and unless: take a Symbol method name and run the rule only when that method says so:
validates :password, length: { minimum: 8 }, allow_blank: true
validates :password, confirmation: true, if: :password_length_ok?
validates :email, presence: true, if: :email_required?The common options can sit at the validates call level and apply to every rule in that call:
validates :nickname, length: { maximum: 40 }, format: { with: /\A[a-z_]+\z/ }, allow_blank: trueA rule-level value wins over the call-level default. presence refuses allow_blank and allow_nil — a rule that can never fail is a declaration bug. message: belongs inside the rule's option hash, not at the call level; a String is used as written, a Symbol is resolved through the message scope.
Collect the errors#
form = PostForm.new({ "title" => "", "body" => "" })
form.valid? # => false
form.errors.size # => 2
form.errors.full_messages
# => ["Title can't be blank", "Body can't be blank"]Messages come from the default English table, or from I18n under the form's scope when the app has registered a locale. message_scope points at a locale subtree:
class SignupForm < Snails::Form
message_scope "activemodel.errors.models.signup.attributes"
endWithout message_scope, keys resolve under activemodel.errors.models.<form_name>.attributes. A custom String message is never looked up; a Symbol key with no built-in default and no I18n raises rather than rendering an empty line.
The errors object answers full_messages, join(separator), on(:field), empty?, any?, and size. errors.add(:base, "...") records a message not tied to a field.
Save it yourself#
A form knows nothing about saving. When validation passes, the controller writes the row:
def create
form = PostForm.new(params)
if form.valid?
post = Post.create!({ title: form.title, body: form.body })
render(json: post_json(post), status: :created)
else
render_form_errors(form)
end
endThe scaffold's render_form_errors turns form.errors.full_messages into {"errors": [...]} with a 422 status. The form never touches the database except for uniqueness's read.
See it work#
ok adapter_test.rb
ok comments_test.rb
ok post_form_test.rb
ok posts_test.rb
ok up_test.rb
5/5 passedRequests and Parameters covers the raw params tree the form receives; Error Handling covers the status mapping around a failed write.