├── .codeclimate.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── evil-struct.gemspec ├── lib ├── evil-struct.rb └── evil │ ├── struct.rb │ └── struct │ ├── attributes.rb │ └── utils.rb └── spec ├── features ├── attributes_spec.rb ├── constructor_aliases_spec.rb ├── constructor_arguments_spec.rb ├── deep_merge_spec.rb ├── equalizer_spec.rb ├── hashifier_spec.rb ├── merge_spec.rb └── shared_options_spec.rb └── spec_helper.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | bundler-audit: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | fixme: 11 | enabled: true 12 | rubocop: 13 | enabled: true 14 | ratings: 15 | paths: 16 | - "**.rb" 17 | exclude_paths: 18 | - spec/ 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AllCops: 3 | DisplayCopNames: true 4 | DisplayStyleGuide: true 5 | StyleGuideCopsOnly: true 6 | TargetRubyVersion: 2.3 7 | 8 | Style/Alias: 9 | Enabled: false 10 | 11 | Style/ClassAndModuleChildren: 12 | EnforcedStyle: compact 13 | 14 | Style/FileName: 15 | Enabled: false 16 | 17 | Style/FrozenStringLiteralComment: 18 | Enabled: false 19 | 20 | Style/ModuleFunction: 21 | Enabled: false 22 | 23 | Style/StringLiterals: 24 | EnforcedStyle: double_quotes 25 | 26 | Style/StringLiteralsInInterpolation: 27 | EnforcedStyle: double_quotes 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | sudo: false 4 | cache: bundler 5 | bundler_args: --without benchmarks tools 6 | before_install: 7 | - gem install bundler --no-ri --no-rdoc 8 | - gem update --system 9 | script: 10 | - bundle exec rake spec 11 | rvm: 12 | - 2.3.0 13 | - 2.4.0 14 | - jruby-9.1.0.0 15 | - rbx-2 16 | - rbx-3 17 | - ruby-head 18 | env: 19 | global: 20 | - JRUBY_OPTS='--dev -J-Xmx1024M' 21 | matrix: 22 | allow_failures: 23 | - rvm: rbx-2 24 | - rvm: rbx-3 25 | - rvm: ruby-head 26 | - rvm: jruby-head 27 | include: 28 | - rvm: jruby-head 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in evil-struct.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Andrew Kozin (nepalez), Evil Martians 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Evil::Struct 2 | 3 | Nested structure with type constraints, based on the [dry-initializer][dry-initializer] DSL. 4 | 5 | 6 | Sponsored by Evil Martians 7 | 8 | [![Gem Version][gem-badger]][gem] 9 | [![Build Status][travis-badger]][travis] 10 | [![Dependency Status][gemnasium-badger]][gemnasium] 11 | [![Inline docs][inch-badger]][inch] 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ```ruby 18 | gem 'evil-struct' 19 | ``` 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | Or install it yourself as: 26 | 27 | $ gem install evil-struct 28 | 29 | ## Synopsis 30 | 31 | The structure is like [dry-struct][dry-struct] except for it controls optional attributes and default values aside of type constraints. 32 | 33 | Its DSL is taken from [dry-initializer][dry-initializer]. Its method `attribute` is just an alias for `option`. 34 | 35 | ```ruby 36 | require "evil-struct" 37 | require "dry-types" 38 | 39 | class Product < Evil::Struct 40 | attribute :title 41 | attribute :price, Dry::Types["coercible.float"] 42 | attribute :quantity, Dry::Types["coercible.int"], default: proc { 0 } 43 | 44 | # shared options 45 | attributes optional: true do 46 | attribute :subtitle 47 | attribute :description 48 | end 49 | end 50 | 51 | # Accepts both symbolic and string keys. 52 | # Class methods `[]`, `call`, and `load` are just aliases for `new` 53 | product = Product[title: "apple", "price" => "10.9", description: "a fruit"] 54 | 55 | # Attributes are available via methods or `[]` 56 | product.title # => "apple" 57 | product[:price] # => 10.9 58 | product["quantity"] # => 0 59 | product.description # => "a fruit" 60 | 61 | # unassigned value differs from `nil` 62 | product.subtitle # => Dry::Initializer::UNDEFINED 63 | 64 | # Raises in case a mandatory value not assigned 65 | Product.new # BOOM! because neither title, nor price are assigned 66 | 67 | # Hashifies all attributes except for undefined subtitle 68 | # You can use `dump` as an alias for `to_h` 69 | product.to_h 70 | # => { title: "apple", price: 10.9, description: "a fruit", quantity: 0 } 71 | 72 | # The structure is comparable to any object that responds to `#to_h` 73 | product == { title: "apple", price: 10.9, description: "a fruit", quantity: 0 } 74 | # => true 75 | ``` 76 | 77 | The structure is designed for immutability. That's why it doesn't contain writers (but you can define them by yourself via `attr_writer`). 78 | 79 | Instead of mutating current instance, you can merge another hash to the object via `merge` or `deep_merge`. 80 | 81 | ```ruby 82 | new_product = product.merge(title: "orange") 83 | new_product.class # => Product 84 | new_product.to_h 85 | # => { title: "orange", price: 10.9, description: "a fruit", quantity: 0 } 86 | 87 | # you can merge any object that responds to `to_h` or `to_hash` 88 | other = Product[title: "orange", price: 12] 89 | new_product = product.merge(other) 90 | new_product.to_h 91 | # => { title: "orange", price: 12, description: "a fruit", quantity: 0 } 92 | 93 | # merge_deeply (deep_merge) gracefully merge nested hashes or hashified objects 94 | grape = Product.new title: "grape", 95 | price: 30, 96 | description: { country: "FR", year: 2016, sort: "Merlot" } 97 | 98 | new_grape = grape.merge_deeply description: { year: 2017 } 99 | new_grape.to_h 100 | # => { 101 | # title: "grape", 102 | # price: 30, 103 | # description: { country: "FR", year: 2017, sort: "Merlot" } 104 | # } 105 | ``` 106 | 107 | ## Compatibility 108 | 109 | Tested under rubies [compatible to MRI 2.2+](.travis.yml). 110 | 111 | ## Contributing 112 | 113 | * [Fork the project](https://github.com/dry-rb/dry-initializer) 114 | * Create your feature branch (`git checkout -b my-new-feature`) 115 | * Add tests for it 116 | * Commit your changes (`git commit -am '[UPDATE] Add some feature'`) 117 | * Push to the branch (`git push origin my-new-feature`) 118 | * Create a new Pull Request 119 | 120 | ## License 121 | 122 | The gem is available as open source under the terms of the [MIT License](./LICENSE.txt). 123 | 124 | [dry-initializer]: https://rom-rb.org/gems/dry-initializer 125 | [dry-struct]: https://rom-rb.org/gems/dry-struct 126 | [gem-badger]: https://img.shields.io/gem/v/evil-struct.svg?style=flat 127 | [gem]: https://rubygems.org/gems/evil-struct 128 | [gemnasium-badger]: https://img.shields.io/gemnasium/evilmartians/evil-struct.svg?style=flat 129 | [gemnasium]: https://gemnasium.com/evilmartians/evil-struct 130 | [inch-badger]: http://inch-ci.org/github/evilmartians/evil-struct.svg 131 | [inch]: https://inch-ci.org/github/evilmartians/evil-struct 132 | [travis-badger]: https://img.shields.io/travis/evilmartians/evil-struct/master.svg?style=flat 133 | [travis]: https://travis-ci.org/evilmartians/evil-struct 134 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /evil-struct.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |gem| 2 | gem.name = "evil-struct" 3 | gem.version = "0.0.4" 4 | gem.author = "Andrew Kozin (nepalez)" 5 | gem.email = "andrew.kozin@gmail.com" 6 | gem.homepage = "https://github.com/evilmartians/evil-struct" 7 | gem.summary = "Structure with type constraints based on dry-initializer" 8 | gem.license = "MIT" 9 | 10 | gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 11 | gem.test_files = gem.files.grep(/^spec/) 12 | gem.extra_rdoc_files = Dir["README.md", "LICENSE", "CHANGELOG.md"] 13 | 14 | gem.required_ruby_version = ">= 2.3" 15 | 16 | gem.add_runtime_dependency "dry-initializer", "~> 1.1" 17 | 18 | gem.add_development_dependency "dry-types", "> 0.9" 19 | gem.add_development_dependency "rspec", "~> 3.0" 20 | gem.add_development_dependency "rake", "> 11" 21 | gem.add_development_dependency "rubocop", ">= 0.44" 22 | end 23 | -------------------------------------------------------------------------------- /lib/evil-struct.rb: -------------------------------------------------------------------------------- 1 | # Namespace for gems created by Evil Martians (http://evilmartians.com) 2 | module Evil 3 | require_relative "evil/struct" 4 | end 5 | -------------------------------------------------------------------------------- /lib/evil/struct.rb: -------------------------------------------------------------------------------- 1 | require "dry-initializer" 2 | 3 | # Nested structure with type constraints, based on the `dry-initializer` DSL 4 | class Evil::Struct 5 | extend Dry::Initializer::Mixin 6 | 7 | require_relative "struct/attributes" 8 | require_relative "struct/utils" 9 | 10 | class << self 11 | # Builds a struct from value that respond to `to_h` or `to_hash` 12 | # 13 | # @param [#to_h, #to_hash] value (nil) 14 | # @return [Evil::Struct] 15 | # 16 | # @alias :call 17 | # @alias :[] 18 | # @alias :load 19 | # 20 | def new(value = {}) 21 | value if value.instance_of? self.class 22 | 23 | hash = value if value.is_a? Hash 24 | hash ||= value.to_h if value.respond_to? :to_h 25 | hash ||= value.to_hash if value.respond_to? :to_hash 26 | 27 | hash_with_symbolic_keys = hash.each_with_object({}) do |(key, val), obj| 28 | obj[key.to_sym] = val 29 | end 30 | super hash_with_symbolic_keys 31 | end 32 | alias_method :call, :new 33 | alias_method :[], :new 34 | alias_method :load, :new 35 | 36 | # @!method attributes(options) 37 | # Shares options between definitions made inside the block 38 | # 39 | # @example 40 | # attributes optional: true do 41 | # attribute :foo 42 | # attribute :bar 43 | # end 44 | # 45 | # @option options (see #attribute) 46 | # @return [self] itself 47 | # 48 | def attributes(**options, &block) 49 | Attributes.call(self, options, &block) 50 | self 51 | end 52 | 53 | # Returns the list of defined attributes 54 | # 55 | # @return [Array] 56 | # 57 | def list_of_attributes 58 | @list_of_attributes ||= [] 59 | end 60 | 61 | # @!method attribute(name, type = nil, options) 62 | # Declares the attribute 63 | # 64 | # @param [#to_sym] name The name of the key 65 | # @param [#call] type (nil) The type constraint 66 | # @option options [#call] :type Type constraint (alternative syntax) 67 | # @option options [#to_sym] :as The name of the attribute 68 | # @option options [Proc] :default Block returning a default value 69 | # @option options [Boolean] :optional (nil) Whether key is optional 70 | # @return [self] 71 | # 72 | # @alias :option 73 | # @alias :param 74 | # 75 | def option(name, type = nil, as: nil, **opts) 76 | super.tap { list_of_attributes << (as || name).to_sym } 77 | self 78 | end 79 | alias_method :attribute, :option 80 | alias_method :param, :option 81 | 82 | private 83 | 84 | def inherited(klass) 85 | super 86 | klass.instance_variable_set :@list_of_attributes, list_of_attributes.dup 87 | end 88 | end 89 | 90 | # Checks an equality to other object that respond to `to_h` or `to_hash` 91 | # 92 | # @param [Object] other 93 | # @return [Boolean] 94 | # 95 | def ==(other) 96 | if other&.respond_to?(:to_h) 97 | to_h == other.to_h 98 | elsif other.respond_to?(:to_hash) 99 | to_h == other.to_hash 100 | else 101 | false 102 | end 103 | end 104 | 105 | # Converts nested structure to hash 106 | # 107 | # Makes conversion through nested hashes, arrays, enumerables, as well 108 | # as trhough values that respond to `to_a`, `to_h`, and `to_hash`. 109 | # Doesn't convert `nil`. 110 | # 111 | # @return [Hash] 112 | # 113 | # @alias :to_hash 114 | # @alias :dump 115 | # 116 | def to_h 117 | self.class.list_of_attributes.each_with_object({}) do |key, hash| 118 | val = instance_variable_get :"@#{key}" 119 | hash[key] = Utils.hashify(val) unless val == Dry::Initializer::UNDEFINED 120 | end 121 | end 122 | alias_method :to_hash, :to_h 123 | alias_method :dump, :to_h 124 | 125 | # @!method [](key) 126 | # Gets the attribute value by name 127 | # 128 | # @example 129 | # class User < Evil::Struct 130 | # attribute :name 131 | # end 132 | # 133 | # joe = User.new(name: "Joe") 134 | # joe.name # => "Joe" 135 | # joe[:name] # => "Joe" 136 | # joe["name"] # => "Joe" 137 | # 138 | # @param [Symbol, String] key The name of the attribute 139 | # @return [Object] A value of the attribute 140 | # 141 | alias_method :[], :send 142 | 143 | # Shallowly merges other object to the current struct 144 | # 145 | # @example 146 | # class User < Evil::Struct 147 | # attribute :name 148 | # attribute :age 149 | # end 150 | # joe_at_3 = User.new(name: "Joe", age: 3) 151 | # 152 | # joe_at_4 = joe_at_3.merge(age: 4) 153 | # joe_at_4.name # => "Joe" 154 | # joe_at_4.age # => 4 155 | # 156 | # @param [Hash, #to_h, #to_hash] other 157 | # @return [self.class] new instance of the current class 158 | # 159 | def merge(other) 160 | self.class[Utils.merge(to_h, other)] 161 | end 162 | 163 | # Deeply merges other object to the current struct 164 | # 165 | # It iterates through hashes and objects responding to `to_h` and `to_hash`. 166 | # The iteration stops when any non-hash value reached. 167 | # 168 | # @example 169 | # class User < Evil::Struct 170 | # attribute :info 171 | # attribute :meta 172 | # end 173 | # user = User.new info: { names: [{ first: "Joe", last: "Doe" }], age: 33 }, 174 | # meta: { type: :admin } 175 | # 176 | # user.merge info: { names: [{ first: "John" }] }, meta: { "role" => :cto } 177 | # user.to_h # => { 178 | # # info: { names: [{ first: "John" }], age: 33 }, 179 | # # meta: { type: :admin, role: :cto } 180 | # # } 181 | # 182 | # @param [Hash, #to_h, #to_hash] other 183 | # @return [self.class] new instance of the current class 184 | # 185 | # @alias :deep_merge 186 | # 187 | def merge_deeply(other) 188 | self.class[Utils.merge_deeply(self, other)] 189 | end 190 | alias_method :deep_merge, :merge_deeply 191 | end 192 | -------------------------------------------------------------------------------- /lib/evil/struct/attributes.rb: -------------------------------------------------------------------------------- 1 | # Handler for shared options 2 | class Evil::Struct::Attributes 3 | # @private 4 | def self.call(*args, &block) 5 | new(*args).instance_eval(&block) 6 | end 7 | 8 | private 9 | 10 | def initialize(klass, **options) 11 | @klass = klass 12 | @options = options 13 | end 14 | 15 | # Declares an attribute that shares options of the block 16 | # @param (see Struct.attribute) 17 | # @option (see Struct.attribute) 18 | # @return (see Struct.attribute) 19 | def attribute(name, type = nil, **options) 20 | @klass.send :attribute, name, type, @options.merge(options) 21 | end 22 | alias_method :option, :attribute 23 | alias_method :param, :attribute 24 | end 25 | -------------------------------------------------------------------------------- /lib/evil/struct/utils.rb: -------------------------------------------------------------------------------- 1 | # Collection of utility methods to hashify and merge structures 2 | module Evil::Struct::Utils 3 | extend self 4 | 5 | # Converts value to nested hash 6 | # 7 | # Makes conversion through nested hashes, arrays, enumerables, 8 | # and other objects that respond to `to_a`, `to_h`, `to_hash`, or `each`. 9 | # Doesn't convert `nil` (even though it responds to `to_h` and `to_a`). 10 | # 11 | # @param [Object] value 12 | # @return [Hash] 13 | # 14 | def hashify(value) 15 | hash = to_h(value) 16 | list = to_a(value) 17 | 18 | if hash 19 | hash.each_with_object({}) { |(key, val), obj| obj[key] = hashify(val) } 20 | elsif list 21 | list.map { |item| hashify(item) } 22 | else 23 | value 24 | end 25 | end 26 | 27 | # Shallowly merges target object to the source hash 28 | # 29 | # The target object can be hash, or respond to either `to_h`, or `to_hash`. 30 | # Before a merge, the keys of the target object are symbolized. 31 | # 32 | # @param [Hash] source 33 | # @param [Hash, #to_h, #to_hash] target 34 | # @return [Hash] 35 | # 36 | def merge(source, target) 37 | source.merge(to_h(target)) 38 | end 39 | 40 | # Deeply merges target object to the source one 41 | # 42 | # The nesting stops when a first non-hashified value reached. 43 | # It do not merge arrays of hashes! 44 | # 45 | # @param [Object] source 46 | # @param [Object] target 47 | # @return [Object] 48 | # 49 | def merge_deeply(source, target) 50 | source_hash = to_h(source) 51 | target_hash = to_h(target) 52 | return target unless source_hash && target_hash 53 | 54 | keys = (source_hash.keys | target_hash.keys) 55 | keys.each_with_object(source_hash) do |key, obj| 56 | next unless target_hash.key? key 57 | obj[key] = merge_deeply source_hash[key], target_hash[key] 58 | end 59 | end 60 | 61 | private 62 | 63 | def to_h(value) 64 | to_hash(value)&.each_with_object({}) do |(key, val), obj| 65 | obj[key.to_sym] = val 66 | end 67 | end 68 | 69 | def to_hash(value) 70 | if value.is_a? Hash then value 71 | elsif value.is_a? Array then nil 72 | elsif value.nil? then nil 73 | elsif value.respond_to? :to_h then value.to_h 74 | elsif value.respond_to? :to_hash then value.to_hash 75 | end 76 | end 77 | 78 | def to_a(value) 79 | if value.is_a? Array then value 80 | elsif value.is_a? Hash then nil 81 | elsif value.nil? then nil 82 | elsif value.respond_to? :to_a then value.to_a 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/features/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "attributes" do 4 | before do 5 | class Test::Foo < Evil::Struct 6 | attribute :"some argument", as: "qux" 7 | end 8 | end 9 | 10 | let(:struct) { Test::Foo.new "some argument": "bar" } 11 | 12 | it "accessible via method" do 13 | expect(struct.qux).to eq "bar" 14 | end 15 | 16 | it "accessible by symbolic key" do 17 | expect(struct[:qux]).to eq "bar" 18 | end 19 | 20 | it "accessible by string key" do 21 | expect(struct["qux"]).to eq "bar" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/features/constructor_aliases_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "constructor aliases" do 4 | it ".new" do 5 | class Test::Foo < Evil::Struct 6 | attribute :foo 7 | attribute :baz, default: proc { "qux" } 8 | end 9 | 10 | expect(Test::Foo.new foo: "bar").to eq foo: "bar", baz: "qux" 11 | end 12 | 13 | it ".call" do 14 | class Test::Foo < Evil::Struct 15 | attribute :foo 16 | attribute :baz, default: proc { "qux" } 17 | end 18 | 19 | expect(Test::Foo.call foo: "bar").to eq foo: "bar", baz: "qux" 20 | end 21 | 22 | it ".load" do 23 | class Test::Foo < Evil::Struct 24 | attribute :foo 25 | attribute :baz, default: proc { "qux" } 26 | end 27 | 28 | expect(Test::Foo.load foo: "bar").to eq foo: "bar", baz: "qux" 29 | end 30 | 31 | it ".[]" do 32 | class Test::Foo < Evil::Struct 33 | attribute :foo 34 | attribute :baz, default: proc { "qux" } 35 | end 36 | 37 | expect(Test::Foo[foo: "bar"]).to eq foo: "bar", baz: "qux" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/features/constructor_arguments_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "constructor" do 4 | before do 5 | class Test::Foo < Evil::Struct 6 | attribute :foo, default: proc { "qux" } 7 | end 8 | end 9 | 10 | it "accepts hash with symbolic keys" do 11 | expect(Test::Foo.new foo: "bar").to eq foo: "bar" 12 | end 13 | 14 | it "accepts hash with string keys" do 15 | expect(Test::Foo.new "foo" => "bar").to eq foo: "bar" 16 | end 17 | 18 | it "accepts nil" do 19 | expect(Test::Foo.new nil).to eq foo: "qux" 20 | end 21 | 22 | it "accepts no arguments" do 23 | expect(Test::Foo.new).to eq foo: "qux" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/features/deep_merge_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "deep merge" do 4 | before do 5 | class Test::Foo < Evil::Struct 6 | attribute :foo 7 | attribute :bar 8 | end 9 | end 10 | 11 | let(:struct) do 12 | Test::Foo.new foo: { bar: [{ foo: :FOO }] }, 13 | bar: { baz: :FOO, qux: :QUX } 14 | end 15 | 16 | let(:result) do 17 | struct.merge_deeply foo: { bar: [{ qux: :QUX }] }, 18 | bar: { "qux" => :FOO } 19 | end 20 | 21 | it "works" do 22 | expect { result }.not_to change { struct } 23 | 24 | expect(result).to be_instance_of Test::Foo 25 | expect(result).to eq foo: { bar: [{ qux: :QUX }] }, 26 | bar: { baz: :FOO, qux: :FOO } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/features/equalizer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "equalizer" do 4 | it "uses #to_h for comparison" do 5 | class Test::Foo < Evil::Struct 6 | attribute :foo 7 | end 8 | 9 | expect(Test::Foo.new foo: "bar").to eq foo: "bar" 10 | expect(Test::Foo.new foo: "bar").to eq double(to_h: { foo: "bar" }) 11 | end 12 | 13 | it "makes struct not equal to nil" do 14 | class Test::Foo < Evil::Struct 15 | attribute :foo, optional: true 16 | end 17 | 18 | expect(Test::Foo.new).to eq({}) 19 | expect(Test::Foo.new).not_to eq nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/features/hashifier_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "hashifier" do 4 | before do 5 | class Test::Foo < Evil::Struct 6 | attribute :foo 7 | attribute :bar, optional: true 8 | end 9 | end 10 | 11 | it "converts struct to hash" do 12 | result = Test::Foo[foo: "bar", bar: "baz"].to_h 13 | 14 | expect(result).to be_instance_of Hash 15 | expect(result).to eq foo: "bar", bar: "baz" 16 | end 17 | 18 | it "hides unassigned values" do 19 | result = Test::Foo[foo: "bar"].to_h 20 | 21 | expect(result).to eq foo: "bar" 22 | end 23 | 24 | it "has alias .to_hash" do 25 | result = Test::Foo[foo: "bar"].to_hash 26 | 27 | expect(result).to eq foo: "bar" 28 | end 29 | 30 | it "has alias .dump" do 31 | result = Test::Foo[foo: "bar"].dump 32 | 33 | expect(result).to eq foo: "bar" 34 | end 35 | 36 | it "applied deeply" do 37 | data = { foo: double(to_h: { baz: [double(to_hash: { qux: nil })] }) } 38 | 39 | expect(Test::Foo[data].to_h).to eq foo: { baz: [{ qux: nil }] } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/features/merge_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "merge" do 4 | before do 5 | class Test::Foo < Evil::Struct 6 | attribute :foo 7 | attribute :bar 8 | end 9 | end 10 | 11 | let(:struct) { Test::Foo.new foo: :FOO, bar: :BAR } 12 | 13 | it "merges hash with symbol keys" do 14 | other = { bar: :BAZ, baz: :QUX } 15 | expect { struct.merge(other) }.not_to change { struct } 16 | 17 | result = struct.merge(other) 18 | expect(result).to be_instance_of Test::Foo 19 | expect(result).to eq foo: :FOO, bar: :BAZ 20 | end 21 | 22 | it "merges hash with string keys" do 23 | other = { "bar" => :BAZ, "baz" => :QUX } 24 | expect { struct.merge(other) }.not_to change { struct } 25 | 26 | result = struct.merge(other) 27 | expect(result).to be_instance_of Test::Foo 28 | expect(result).to eq foo: :FOO, bar: :BAZ 29 | end 30 | 31 | it "merges objects supporting #to_h" do 32 | other = double to_h: { bar: :BAZ, baz: :QUX } 33 | expect { struct.merge(other) }.not_to change { struct } 34 | 35 | result = struct.merge(other) 36 | expect(result).to be_instance_of Test::Foo 37 | expect(result).to eq foo: :FOO, bar: :BAZ 38 | end 39 | 40 | it "merges objects supporting #to_hash" do 41 | other = double to_hash: { bar: :BAZ, baz: :QUX } 42 | expect { struct.merge(other) }.not_to change { struct } 43 | 44 | result = struct.merge(other) 45 | expect(result).to be_instance_of Test::Foo 46 | expect(result).to eq foo: :FOO, bar: :BAZ 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/features/shared_options_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "shared options" do 4 | it "supported via .attributes" do 5 | class Test::Foo < Evil::Struct 6 | attributes type: Dry::Types["strict.string"], default: proc { "bar" } do 7 | attribute :foo 8 | attribute :baz, default: proc { "qux" } 9 | end 10 | end 11 | 12 | expect(Test::Foo.new).to eq foo: "bar", baz: "qux" 13 | expect { Test::Foo.new foo: 1 }.to raise_error(TypeError) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require "pry" 3 | rescue LoadError 4 | nil 5 | end 6 | 7 | require "evil-struct" 8 | require "dry-types" 9 | 10 | RSpec.configure do |config| 11 | config.order = :random 12 | config.filter_run focus: true 13 | config.run_all_when_everything_filtered = true 14 | 15 | # Prepare the Test namespace for constants defined in specs 16 | config.around(:each) do |example| 17 | Test = Class.new(Module) 18 | example.run 19 | Object.send :remove_const, :Test 20 | end 21 | end 22 | --------------------------------------------------------------------------------