Skip to content
This repository has been archived by the owner on Oct 18, 2020. It is now read-only.

Commit

Permalink
Add shortcut class methods for ad hoc validations (#10)
Browse files Browse the repository at this point in the history
Removes extra space in ValidationError string representation
  • Loading branch information
Blacksmoke16 authored Sep 14, 2019
1 parent 67a266e commit 842d7c4
Show file tree
Hide file tree
Showing 4 changed files with 187 additions and 8 deletions.
120 changes: 119 additions & 1 deletion spec/assert_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,124 @@ class SomeClass
end

describe Assert do
# Will just do a few of each "type"
describe "shortcut methods" do
# Allows any type
describe ".not_nil" do
describe "with a not nil value" do
it "should be true" do
Assert.not_nil(17).should be_true
end
end

describe "with a nil value" do
it "should be false" do
Assert.not_nil(nil).should be_false
end
end
end

describe ".not_nil!" do
describe "with a not nil value" do
it "should be true" do
Assert.not_nil!(17).should be_true
end
end

describe "with a nil value" do
it "should raise an exception" do
expect_raises Assert::Exceptions::ValidationError, "Validation tests failed: 'actual' should not be null" do
Assert.not_nil!(nil)
end
end
end

describe "with a custom message" do
it "should raise an exception with the given message" do
expect_raises Assert::Exceptions::ValidationError, "Validation tests failed: Age should not be nil" do
Assert.not_nil!(nil, message: "Age should not be nil")
end
end
end
end

# Allows specific type
describe ".not_blank" do
describe "with a non blank value" do
it "should be true" do
Assert.not_blank("foo").should be_true
end
end

describe "with a blank value" do
it "should be false" do
Assert.not_blank(" ").should be_false
end
end

describe "with a normalizer" do
it "should be normalized to be true" do
Assert.not_blank(" ", normalizer: ->(actual : String) { actual + 'f' }).should be_true
end
end
end

describe ".not_blank!" do
describe "with a non blank value" do
it "should be true" do
Assert.not_blank!("foo").should be_true
end
end

describe "with a blank value" do
it "should raise an exception" do
expect_raises Assert::Exceptions::ValidationError, "Validation tests failed: 'actual' should not be blank" do
Assert.not_blank!("")
end
end
end
end

# Has multiple types
describe ".choice" do
describe "with a valid choice" do
it "should be true" do
Assert.choice("Choice", ["One", "Foo", "Choice"]).should be_true
end
end

describe "with an invalid choice" do
it "should be false" do
Assert.choice("Bar", ["One", "Foo", "Choice"]).should be_false
end
end
end

describe ".choice!" do
describe "with a valid choice" do
it "should be true" do
Assert.choice!("Choice", ["One", "Foo", "Choice"]).should be_true
end
end

describe "with an invalid choice" do
it "should raise an exception" do
expect_raises Assert::Exceptions::ValidationError, "Validation tests failed: 'actual' is not a valid choice" do
Assert.choice!("Bar", ["One", "Foo", "Choice"])
end
end
end

describe "with a custom message" do
it "should raise an exception with the given message" do
expect_raises Assert::Exceptions::ValidationError, "Validation tests failed: Invalid choice: Bar" do
Assert.choice!("Bar", ["One", "Foo", "Choice"], message: "Invalid choice: %{actual}")
end
end
end
end
end

describe "#valid?" do
describe "with a valid object" do
it "should return true" do
Expand Down Expand Up @@ -101,7 +219,7 @@ describe Assert do
it "should preserve the message if it was changed" do
SomeClass.new.validate!
rescue ex : Assert::Exceptions::ValidationError
ex.to_s.should eq "Validation tests failed: 'exact_value' is not the proper size. It should have exactly 5 character(s)"
ex.to_s.should eq "Validation tests failed: 'exact_value' is not the proper size. It should have exactly 5 character(s)"
end

describe "with a valid object" do
Expand Down
14 changes: 11 additions & 3 deletions spec/exceptions/validation_error_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ require "../spec_helper"

describe Assert::Exceptions::ValidationError do
describe "#new" do
it "returns the correct message" do
Assert::Exceptions::ValidationError.new([] of Assert::Assertions::Assertion).message.should eq "Validation tests failed"
describe Array do
it "accepts an array of assertions" do
Assert::Exceptions::ValidationError.new([] of Assert::Assertions::Assertion).message.should eq "Validation tests failed"
end
end

describe Assert::Assertions::Assertion do
it "accepts a single assertion" do
Assert::Exceptions::ValidationError.new(Assert::Assertions::NotBlank(String?).new("name", "")).message.should eq "Validation tests failed"
end
end
end

Expand Down Expand Up @@ -36,7 +44,7 @@ describe Assert::Exceptions::ValidationError do
Assert::Assertions::GreaterThanOrEqual(Int32).new("age", -1, 0),
])

error.to_s.should eq "Validation tests failed: 'name' should not be blank, 'age' should be greater than or equal to '0'"
error.to_s.should eq "Validation tests failed: 'name' should not be blank, 'age' should be greater than or equal to '0'"
end
end
end
53 changes: 51 additions & 2 deletions src/assert.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ require "./exceptions/*"

# Annotation based object validation library.
#
# See `Assert::Assertions` for the full list, and each assertion for more detailed information/examples.
# See the `Assert::Assertions` namespace for the full assertion list as well as each assertion class for more detailed information/examples.
#
# See `Assert::Assertions::Assertion` for assertion usage documentation.
# See `Assert::Assertions::Assertion` for common/high level assertion usage documentation.
#
# ### Example Usage
#
# `Assert` supports both object based validations via annotations as well as ad hoc value validations via class methods.
# #### Object Validation
#
# ```
# require "assert"
#
Expand Down Expand Up @@ -58,6 +62,27 @@ require "./exceptions/*"
# ex.to_json # => {"code":400,"message":"Validation tests failed","errors":["'foobar' is not a proper email","'password' is too short. It should have 7 character(s) or more"]}
# end
# ```
#
# #### Ad Hoc Validation
# ```
# # Each assertion automatically defines a shortcut class method for ad hoc validations.
# Assert.not_blank "foo" # => true
# Assert.not_blank "" # => false
#
# begin
# # The bang version will raise if the value is invalid.
# Assert.not_blank! " "
# rescue ex
# ex.to_s # => Validation tests failed: 'actual' should not be blank
# end
#
# begin
# # Optional arguments can be used just like the annotation versions.
# Assert.equal_to! 15, 20, message: "%{actual} does not equal %{value}"
# rescue ex
# ex.to_s # => Validation tests failed: 15 does not equal 20
# end
# ```
module Assert
# Define the Assertion annotations.
macro finished
Expand All @@ -68,6 +93,30 @@ module Assert
{% raise "#{assertion.name} must apply the `Assert::Assertions::Register` annotation." unless ann %}
# :nodoc:
annotation {{ann[:annotation].id}}; end

{% initializer = assertion.methods.find &.name.==("initialize") %}
{% method_name = ann[:annotation].stringify.split("::").last.underscore.id %}
{% method_args = initializer.args[1..-1] %}
{% method_vars = initializer.args[1..-1].map(&.name).splat %}
{% free_variables = assertion.type_vars %}
{% generic_args = free_variables.size > 1 ? ",#{free_variables[1..-1].splat}".id : "".id %}

# `{{assertion.stringify.gsub(/\(.*\)/, "").id}}` assertion shortcut method.
#
# Can be used for ad hoc validations when applying annotations is not possible.
def self.{{method_name}}({{method_args.splat}}) : Bool forall {{free_variables.splat}}
assertion = {{assertion.name.gsub(/\(.*\)/, "").id}}({{method_args.first.restriction}}{{generic_args}}).new(\{{@def.args.first.name.stringify}}, {{method_vars}})
assertion.valid?
end

# `{{assertion.stringify.gsub(/\(.*\)/, "").id}}` assertion shortcut method.
#
# Can be used for ad hoc validations when applying annotations is not possible.
# Raises an `Assert::Exceptions::ValidationError` if the value is not valid.
def self.{{method_name}}!({{method_args.splat}}) : Bool forall {{free_variables.splat}}
assertion = {{assertion.name.gsub(/\(.*\)/, "").id}}({{method_args.first.restriction}}{{generic_args}}).new(\{{@def.args.first.name.stringify}}, {{method_vars}})
assertion.valid? || raise Assert::Exceptions::ValidationError.new assertion
end
{% end %}
{% end %}
{% end %}
Expand Down
8 changes: 6 additions & 2 deletions src/exceptions/validation_error.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ require "json"

# Represents a validation error. It can be raised manually or via `Assert#validate!`.
class Assert::Exceptions::ValidationError < Exception
def self.new(failed_assertion : Assert::Assertions::Assertion)
new [failed_assertion] of Assert::Assertions::Assertion
end

def initialize(@failed_assertions : Array(Assert::Assertions::Assertion))
super "Validation tests failed"
end
Expand Down Expand Up @@ -41,11 +45,11 @@ class Assert::Exceptions::ValidationError < Exception
# Assert::Assertions::GreaterThanOrEqual(Int32).new("age", -1, 0),
# ])
#
# error.to_s # => "Validation tests failed: 'name' should not be blank, 'age' should be greater than or equal to '0'"
# error.to_s # => "Validation tests failed: 'name' should not be blank, 'age' should be greater than or equal to '0'"
# ```
def to_s : String
String.build do |str|
str << "Validation tests failed: "
str << "Validation tests failed: "
@failed_assertions.map(&.message).join(", ", str)
end
end
Expand Down

0 comments on commit 842d7c4

Please sign in to comment.