Validation frontend for models.
Scrivener removes the validation responsibility from models and acts as a filter for whitelisted attributes.
A model may expose different APIs to satisfy different purposes. For example, the set of validations for a User in a Sign up process may not be the same as the one exposed to an Admin when editing a user profile. While you want the User to provide an email, a password and a password confirmation, you probably don't want the admin to mess with those attributes at all.
In a wizard, different model states ask for different validations, and a single set of validations for the whole process is not the best solution.
Scrivener is Bureaucrat's little brother. It draws all the inspiration from it and its features are a subset of Bureaucrat's. For a more robust and tested solution, please check it.
This library exists to satify the need of extracting Ohm's validations for reuse in other scenarios.
Using Scrivener feels very natural no matter what underlying model you are using. As it provides its own validation and whitelisting features, you can choose to ignore the ones that come bundled with ORMs.
This short example illustrates how to move the validation and whitelisting responsibilities away from the model and into Scrivener:
# We use Sequel::Model in this example, but it applies to other ORMs such
# as Ohm or ActiveRecord.
class Article < Sequel::Model
# Whitelist for mass assigned attributes.
set_allowed_columns :title, :body, :state
# Validations for all contexts.
def validate
validates_presence :title
validates_presence :body
validates_presence :state
end
end
title = "Bartleby, the Scrivener"
body = "I am a rather elderly man..."
# When using the model...
article = Article.new(title: title, body: body)
article.valid? #=> false
article.errors[:state] #=> [:not_present]
Of course, what you would do instead is declare :title
and :body
as allowed
columns, then assign :state
using the attribute accessor. The reason for this
example is to show how you need to work around the fact that there's a single
declaration for allowed columns and validations, which in many cases is a great
feature and in others is a minor obstacle.
Now see what happens with Scrivener:
# Now the model has no validations or whitelists. It may still have schema
# constraints, which is a good practice to enforce data integrity.
class Article < Sequel::Model
end
# The attribute accessors are the only fields that will be set. If more
# fields are sent when using mass assignment, a NoMethodError exception is
# raised.
#
# Note how in this example we don't accept the status attribute.
class Edit < Scrivener
attr_accessor :title
attr_accessor :body
def validate
assert_present :title
assert_present :body
end
end
edit = Edit.new(title: title, body: body)
edit.valid? #=> true
article = Article.new(edit.attributes)
article.save
# And now we only ask for the status.
class Publish < Scrivener
attr_accessor :status
def validate
assert_format :status, /^(published|draft)$/
end
end
publish = Publish.new(status: "published")
publish.valid? #=> true
article.update_attributes(publish.attributes)
# If we try to change other fields...
publish = Publish.new(status: "published", title: "foo")
#=> NoMethodError: undefined method `title=' for #<Publish...>
It's important to note that using Scrivener implies a greater risk than using the model validations. Having a central repository of mass assignable attributes and validations is more secure in most scenarios.
If you don't need all the attributes after the filtering is done, you can fetch just the ones you need. For example:
class SignUp < Scrivener
attr_accessor :email
attr_accessor :password
attr_accessor :password_confirmation
def validate
assert_email :email
if assert_present :password
assert_equal :password, :password_confirmation
end
end
def assert_equal(f1, f2)
assert send(f1) == send(f2), [f1, f2, :not_equal]
end
end
filter = SignUp.new(email: "[email protected]",
password: "monkey",
password_confirmation: "monkey")
# If the validation succeeds, we only need email and password to
# create a new user, and we can discard the password_confirmation.
if filter.valid?
User.create(filter.slice(:email, :password))
end
By calling slice
with a list of attributes, you get a hash with only
those key/value pairs.
Scrivener ships with some basic assertions. The following is a brief description for each of them:
The assert
method is used by all the other assertions. It pushes the
second parameter to the list of errors if the first parameter evaluates
to false.
def assert(value, error)
value or errors[error.first].push(error.last) && false
end
Checks that the given field is not nil or empty. The error code for this
assertion is :not_present
.
Check that the attribute has the expected value. It uses === for
comparison, so type checks are possible too. Note that in order to
make the case equality work, the check inverts the order of the
arguments: assert_equal :foo, Bar
is translated to the expression
Bar === send(:foo)
.
Checks that the given field matches the provided regular expression.
The error code for this assertion is :format
.
Checks that the given field holds a number as a Fixnum or as a string
representation. The error code for this assertion is :not_numeric
.
Provides a pretty general URL regular expression match. An important
point to make is that this assumes that the URL should start with
http://
or https://
. The error code for this assertion is
:not_url
.
In this current day and age, almost all web applications need to
validate an email address. This pretty much matches 99% of the emails
out there. The error code for this assertion is :not_email
.
Checks that a given field is contained within a set of values (i.e.
like an ENUM
).
def validate
assert_member :state, %w{pending paid delivered}
end
The error code for this assertion is :not_valid
Checks that a given field's length falls under a specified range.
def validate
assert_length :username, 3..20
end
The error code for this assertion is :not_in_range
.
Checks that a given field looks like a number in the human sense of the word. Valid numbers are: 0.1, .1, 1, 1.1, 3.14159, etc.
The error code for this assertion is :not_decimal
.
$ gem install scrivener