Skip to content

Commit

Permalink
Merge pull request #14 from davidrunger/active-model-validations
Browse files Browse the repository at this point in the history
Add ability to specify ActiveModel-style validations for Class shapes
  • Loading branch information
davidrunger authored Jun 19, 2020
2 parents 39c8ad9 + 7827422 commit b741f6b
Show file tree
Hide file tree
Showing 11 changed files with 283 additions and 41 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased (0.4.0.alpha)
### Added
- Add the ability to specify ActiveModel-style validations for `Shaped::Shape::Class`es

## v0.3.2 (2020-06-18)
### Tests
- Add tests for invalid `Array` and `Or` shape definitions
Expand Down
5 changes: 4 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ GIT
PATH
remote: .
specs:
shaped (0.3.2)
shaped (0.4.0.alpha)
activemodel (~> 6.0)
activesupport (~> 6.0)

GEM
remote: https://rubygems.org/
specs:
activemodel (6.0.3.2)
activesupport (= 6.0.3.2)
activesupport (6.0.3.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
Expand Down
12 changes: 9 additions & 3 deletions lib/shaped.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@ module Shaped ; end
module Shaped::Shapes ; end

require 'active_support/all'
require 'active_model'
require_relative './shaped/shape.rb'
Dir[File.dirname(__FILE__) + '/**/*.rb'].sort.each { |file| require file }

module Shaped
# rubocop:disable Naming/MethodName
def self.Shape(*shape_descriptions)
validation_options = shape_descriptions.extract_options!
if shape_descriptions.size >= 2
Shaped::Shapes::Or.new(*shape_descriptions)
Shaped::Shapes::Or.new(*shape_descriptions, validation_options)
else
shape_description = shape_descriptions.first
# If the shape_descriptions argument list was just one hash, then `extract_options!` would
# have removed it, making `shape_descriptions` an empty array, so we need to "restore" the
# "validation options" to their actual role of a Hash `shape_description` here.
shape_description = shape_descriptions.first || validation_options

case shape_description
when Hash then Shaped::Shapes::Hash.new(shape_description)
when Array then Shaped::Shapes::Array.new(shape_description)
when Class then Shaped::Shapes::Class.new(shape_description)
when Class then Shaped::Shapes::Class.new(shape_description, validation_options)
else Shaped::Shapes::Equality.new(shape_description)
end
end
Expand Down
45 changes: 40 additions & 5 deletions lib/shaped/shapes/class.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
# frozen_string_literal: true

class Shaped::Shapes::Class < Shaped::Shape
def initialize(shape_description)
if !shape_description.is_a?(Class)
def initialize(expected_klass, validations = {})
if !expected_klass.is_a?(Class)
raise(Shaped::InvalidShapeDescription, "A #{self.class} description must be a Class.")
end

@expected_klass = shape_description
@expected_klass = expected_klass
@validations = validations
@validator_klass = validator_klass(validations)
end

def matched_by?(object)
object.is_a?(@expected_klass)
object.is_a?(@expected_klass) && validations_satisfied?(object)
end

def to_s
@expected_klass.name
if @validations.empty?
@expected_klass.name
else
"#{@expected_klass} validating #{@validations}"
end
end

private

def validator_klass(validations)
return nil if validations.empty?

Class.new do
include ActiveModel::Validations

attr_accessor :value

validates :value, validations

class << self
# ActiveModel requires the class to have a `name`
def name
'Shaped::Shapes::Class::AnonymousValidator'
end
end
end
end

def validations_satisfied?(object)
return true if @validator_klass.nil?

validator_instance = @validator_klass.new
validator_instance.value = object
validator_instance.valid?
end
end
8 changes: 6 additions & 2 deletions lib/shaped/shapes/or.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@

class Shaped::Shapes::Or < Shaped::Shape
def initialize(*shape_descriptions)
validation_options = shape_descriptions.extract_options!
if shape_descriptions.size <= 1
raise(Shaped::InvalidShapeDescription, <<~ERROR.squish)
A #{self.class} description must be a list of two or more shape descriptions.
ERROR
end

@shapes = shape_descriptions.map { |description| Shaped::Shape(description) }
@shapes =
shape_descriptions.map do |description|
Shaped::Shape(description, validation_options)
end
end

def matched_by?(object)
@shapes.any? { |shape| shape.matched_by?(object) }
end

def to_s
@shapes.map(&:to_s).join(', ')
@shapes.map(&:to_s).join(' OR ')
end
end
2 changes: 1 addition & 1 deletion lib/shaped/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Shaped
VERSION = '0.3.2'
VERSION = '0.4.0.alpha'
end
1 change: 1 addition & 0 deletions shaped.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ Gem::Specification.new do |spec|
end
spec.require_paths = ['lib']

spec.add_runtime_dependency('activemodel', '~> 6.0')
spec.add_runtime_dependency('activesupport', '~> 6.0')
end
81 changes: 81 additions & 0 deletions spec/shaped_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,86 @@
end
end
end

context 'when called with one class plus ActiveModel validation options' do
subject(:shape) { Shaped::Shape(Numeric, numericality: { greater_than: 21 }) }

it 'returns an instance of Shaped::Shapes::Class' do
expect(shape).to be_a(Shaped::Shapes::Class)
end
end

context 'when called with two classes plus ActiveModel validation options' do
subject(:shape) { Shaped::Shape(Float, Integer, numericality: { greater_than: min_value }) }

let(:min_value) { 21 }

it 'returns an instance of Shaped::Shapes::Or' do
expect(shape).to be_a(Shaped::Shapes::Or)
end

describe '#matched_by? for the returned `Or` shape' do
subject(:matched_by?) { shape.matched_by?(test_object) }

context 'when the test object is an instance of the first listed allowed class' do
before { expect(test_object).to be_a(Float) }

context 'when the test object meets the ActiveModel validation' do
before { expect(test_object).to be > min_value }

let(:test_object) { 22.2 }

it 'returns true' do
expect(matched_by?).to eq(true)
end
end

context 'when the test object does not meet the ActiveModel validation' do
before { expect(test_object).not_to be > min_value }

let(:test_object) { 18.9 }

it 'returns false' do
expect(matched_by?).to eq(false)
end
end
end

context 'when the test object is an instance of the second listed allowed class' do
before { expect(test_object).to be_a(Integer) }

context 'when the test object meets the ActiveModel validation' do
before { expect(test_object).to be > min_value }

let(:test_object) { 30 }

it 'returns true' do
expect(matched_by?).to eq(true)
end
end

context 'when the test object does not meet the ActiveModel validation' do
before { expect(test_object).not_to be > min_value }

let(:test_object) { 10 }

it 'returns false' do
expect(matched_by?).to eq(false)
end
end
end
end

describe '#to_s for the returned `Or` shape' do
subject(:to_s) { shape.to_s }

it 'returns a good description of the shape' do
expect(to_s).to eq(<<~TO_S.squish)
Float validating {:numericality=>{:greater_than=>21}} OR Integer validating
{:numericality=>{:greater_than=>21}}
TO_S
end
end
end
end
end
4 changes: 2 additions & 2 deletions spec/shapes/array_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,15 @@
subject(:array_shape) { Shaped::Shapes::Array.new([String, Numeric]) }

it 'returns a readably formatted description of the expected array shape' do
expect(to_s).to eq('[String, Numeric]')
expect(to_s).to eq('[String OR Numeric]')
end
end

context 'when the array shape is a list of multiple objects' do
subject(:array_shape) { Shaped::Shapes::Array.new(%w[two four six]) }

it 'returns a readably formatted description of the allowed object values' do
expect(to_s).to eq('["two", "four", "six"]')
expect(to_s).to eq('["two" OR "four" OR "six"]')
end
end
end
Expand Down
Loading

0 comments on commit b741f6b

Please sign in to comment.