Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow setting violations on mock validators + Choice constraint #7

Merged
merged 5 commits into from
Nov 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: athena-validator

version: 0.1.1
version: 0.1.2

crystal: 0.35.0

Expand Down
100 changes: 100 additions & 0 deletions spec/constraints/choice_validator_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
require "../spec_helper"

private alias CONSTRAINT = AVD::Constraints::Choice

struct ChoiceValidatorTest < AVD::Spec::ConstraintValidatorTestCase
def test_requires_enumerable_if_multiple_is_true : Nil
expect_raises AVD::Exceptions::UnexpectedValueError, "Enumerable" do
self.validator.validate "foo", self.new_constraint choices: ["foo", "bar"], multiple: true
end
end

def test_requires_enumerable_if_multiple_is_false : Nil
expect_raises AVD::Exceptions::UnexpectedValueError, "Enumerable" do
self.validator.validate [1, 2], self.new_constraint choices: ["foo", "bar"], multiple: false
end
end

def test_nil_is_valid : Nil
self.validator.validate nil, self.new_constraint choices: ["foo", "bar"]
self.assert_no_violation
end

def test_valid_choice : Nil
self.validator.validate "bar", self.new_constraint choices: ["foo", "bar"]
self.assert_no_violation
end

def test_multiple_choices : Nil
self.validator.validate ["foo", "bar"], self.new_constraint choices: ["foo", "bar"], multiple: true
self.assert_no_violation
end

def test_invalid_choice : Nil
self.validator.validate "baz", self.new_constraint choices: ["foo", "bar"], message: "my_message"

self
.build_violation("my_message", CONSTRAINT::NO_SUCH_CHOICE_ERROR, "baz")
.add_parameter("{{ choices }}", ["foo", "bar"])
.assert_violation
end

def test_invalid_choice_empty_choices_array : Nil
self.validator.validate "baz", self.new_constraint choices: [] of String, message: "my_message"

self
.build_violation("my_message", CONSTRAINT::NO_SUCH_CHOICE_ERROR, "baz")
.add_parameter("{{ choices }}", [] of String)
.assert_violation
end

def test_invalid_choices_multiple : Nil
self.validator.validate ["foo", "baz"], self.new_constraint choices: ["foo", "bar"], multiple: true, multiple_message: "my_message"

self
.build_violation("my_message", CONSTRAINT::NO_SUCH_CHOICE_ERROR, "baz")
.add_parameter("{{ choices }}", ["foo", "bar"])
.invalid_value("baz")
.assert_violation
end

def test_invalid_choices_too_few : Nil
value = ["foo"]

self.value = value

self.validator.validate value, self.new_constraint choices: ["foo", "bar", "moo", "maa"], multiple: true, range: (2..), min_message: "my_message"

self
.build_violation("my_message", CONSTRAINT::TOO_FEW_ERROR, value)
.add_parameter("{{ limit }}", 2)
.add_parameter("{{ choices }}", ["foo", "bar", "moo", "maa"])
.invalid_value(value)
.plural(2)
.assert_violation
end

def test_invalid_choices_too_many : Nil
value = ["foo", "bar", "moo"]

self.value = value

self.validator.validate value, self.new_constraint choices: ["foo", "bar", "moo", "maa"], multiple: true, range: (..2), max_message: "my_message"

self
.build_violation("my_message", CONSTRAINT::TOO_MANY_ERROR, value)
.add_parameter("{{ limit }}", 2)
.add_parameter("{{ choices }}", ["foo", "bar", "moo", "maa"])
.invalid_value(value)
.plural(2)
.assert_violation
end

private def create_validator : AVD::ConstraintValidatorInterface
CONSTRAINT::Validator.new
end

private def constraint_class : AVD::Constraint.class
CONSTRAINT
end
end
216 changes: 216 additions & 0 deletions src/constraints/choice.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# Validates that a value is one of a given set of valid choices;
# can also be used to validate that each item in a collection is one of those valid values.
#
# ## Configuration
#
# ### Required Arguments
#
# #### choices
#
# **Type:** `Array(String | Number::Primitive | Symbol)`
#
# The choices that are considered valid.
#
# ### Optional Arguments
#
# #### message
#
# **Type:** `String` **Default:** `This value is not a valid choice.`
#
# The message that will be shown if the value is not a valid choice and [multiple](#multiple) is `false`.
#
# ##### Placeholders
#
# The following placeholders can be used in this message:
#
# * `{{ value }}` - The current (invalid) value.
# * `{{ choices }}` - The available choices.
#
# #### multiple_message
#
# **Type:** `String` **Default:** `One or more of the given values is invalid.`
#
# The message that will be shown if one of the values is not a valid choice and [multiple](#multiple) is `true`.
#
# ##### Placeholders
#
# The following placeholders can be used in this message:
#
# * `{{ value }}` - The current (invalid) value.
# * `{{ choices }}` - The available choices.
#
# #### min_message
#
# **Type:** `String` **Default:** `You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices.`
#
# The message that will be shown if too few choices are chosen as per the [range](#range) option.
#
# ##### Placeholders
#
# The following placeholders can be used in this message:
#
# * `{{ value }}` - The current (invalid) value.
# * `{{ choices }}` - The available choices.
# * `{{ limit }}` - If [multiple](#multiple) is true, enforces that at most this many values may be selected in order to be valid.
#
# #### max_message
#
# **Type:** `String` **Default:** `You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices.`
#
# The message that will be shown if too many choices are chosen as per the [range](#range) option.
#
# ##### Placeholders
#
# The following placeholders can be used in this message:
#
# * `{{ value }}` - The current (invalid) value.
# * `{{ choices }}` - The available choices.
# * `{{ limit }}` - If [multiple](#multiple) is true, enforces that no more than this many values may be selected in order to be valid.
#
# #### range
#
# **Type:** `::Range?` **Default:** `nil`
#
# If [multiple](#multiple) is true, is used to define the "range" of how many choices must be valid for the value to be considered valid.
# For example, if set to `(3..)`, but there are only 2 valid items in the input enumerable then validation will fail.
#
# Beginless/endless ranges can be used to define only a lower/upper bound.
#
# #### multiple
#
# **Type:** `Bool` **Default:** `false`
#
# If `true`, the input value is expected to be an `Enumerable` instead of a single scalar value.
# The constraint will check each item in the enumerable is valid choice.
#
# #### groups
#
# **Type:** `Array(String) | String | Nil` **Default:** `nil`
#
# The `AVD:Constraint@validation-groups` this constraint belongs to.
# `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`.
#
# #### payload
#
# **Type:** `Hash(String, String)?` **Default:** `nil`
#
# Any arbitrary domain-specific data that should be stored with this constraint.
# The `AVD::Constraint@payload` is not used by `Athena::Validator`, but its processing is completely up to you.
class Athena::Validator::Constraints::Choice < Athena::Validator::Constraint
NO_SUCH_CHOICE_ERROR = "c7398ea5-e787-4ee9-9fca-5f2c130614d6"
TOO_FEW_ERROR = "3573357d-c9a8-4633-a742-c001086fd5aa"
TOO_MANY_ERROR = "91d0d22b-a693-4b9c-8b41-bc6392cf89f4"

@@error_names = {
NO_SUCH_CHOICE_ERROR => "NO_SUCH_CHOICE_ERROR",
TOO_FEW_ERROR => "TOO_FEW_ERROR",
TOO_MANY_ERROR => "TOO_MANY_ERROR",
}

getter choices : Array(String | Number::Primitive | Symbol)

getter multiple_message : String
getter min_message : String
getter max_message : String

getter min : Number::Primitive?
getter max : Number::Primitive?

getter? multiple : Bool

def self.new(
choices : Array(String | Number::Primitive | Symbol),
message : String = "This value is not a valid choice.",
multiple_message : String = "One or more of the given values is invalid.",
min_message : String = "You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices.",
max_message : String = "You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices.",
multiple : Bool = false,
range : ::Range? = nil,
groups : Array(String) | String | Nil = nil,
payload : Hash(String, String)? = nil
)
new choices.map(&.as(String | Number::Primitive | Symbol)), message, multiple_message, min_message, max_message, multiple, range.try(&.begin), range.try(&.end), groups, payload
end

private def initialize(
@choices : Array(String | Number::Primitive | Symbol),
message : String,
@multiple_message : String,
@min_message : String,
@max_message : String,
@multiple : Bool,
@min : Number::Primitive?,
@max : Number::Primitive?,
groups : Array(String) | String | Nil,
payload : Hash(String, String)?
)
super message, groups, payload
end

struct Validator < Athena::Validator::ConstraintValidator
# :inherit:
def validate(value : Enumerable?, constraint : AVD::Constraints::Choice) : Nil
return if value.nil?

self.raise_invalid_type(value, "Enumerable") unless constraint.multiple?

choices = constraint.choices

value.each do |v|
unless choices.includes? v
self
.context
.build_violation(constraint.multiple_message, NO_SUCH_CHOICE_ERROR, v)
.add_parameter("{{ choices }}", choices)
.invalid_value(v)
.add

return
end
end

size = value.size

if (limit = constraint.min) && (size < limit)
self
.context
.build_violation(constraint.min_message, TOO_FEW_ERROR, value)
.add_parameter("{{ limit }}", limit)
.add_parameter("{{ choices }}", choices)
.plural(limit.to_i)
.invalid_value(value)
.add

return
end

if (limit = constraint.max) && (size > limit)
self
.context
.build_violation(constraint.max_message, TOO_MANY_ERROR, value)
.add_parameter("{{ limit }}", limit)
.add_parameter("{{ choices }}", choices)
.plural(limit.to_i)
.invalid_value(value)
.add

return
end
end

# :inherit:
def validate(value : _, constraint : AVD::Constraints::Choice) : Nil
return if value.nil?

self.raise_invalid_type(value, "Enumerable") if constraint.multiple? && !value.is_a?(Enumerable)

return if constraint.choices.includes? value

self
.context
.build_violation(constraint.message, NO_SUCH_CHOICE_ERROR, value)
.add_parameter("{{ choices }}", constraint.choices)
.add
end
end
end
10 changes: 5 additions & 5 deletions src/constraints/range.cr
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ class Athena::Validator::Constraints::Range < Athena::Validator::Constraint
private def initialize(
@min : Number::Primitive | Time | Nil,
@max : Number::Primitive | Time | Nil,
@not_in_range_message : String = "This value should be between {{ min }} and {{ max }}.",
@min_message : String = "This value should be {{ limit }} or more.",
@max_message : String = "This value should be {{ limit }} or less.",
groups : Array(String) | String | Nil = nil,
payload : Hash(String, String)? = nil
@not_in_range_message : String,
@min_message : String,
@max_message : String,
groups : Array(String) | String | Nil,
payload : Hash(String, String)?
)
super "", groups, payload
end
Expand Down
Loading