├── .circleci └── config.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Changelog.md ├── Gemfile ├── README.md ├── Rakefile ├── anima.gemspec ├── config ├── flay.yml ├── flog.yml ├── mutant.yml ├── reek.yml ├── rubocop.yml └── yardstick.yml ├── lib ├── anima.rb └── anima │ ├── attribute.rb │ └── error.rb ├── mutant.sh └── spec ├── integration └── simple_spec.rb ├── spec_helper.rb └── unit ├── anima ├── attribute_spec.rb └── error_spec.rb └── anima_spec.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/anima 3 | docker: 4 | - image: circleci/ruby:2.6.0 5 | version: 2 6 | jobs: 7 | unit_specs: 8 | <<: *defaults 9 | steps: 10 | - checkout 11 | - run: bundle install 12 | - run: bundle exec rspec spec/unit 13 | metrics: 14 | <<: *defaults 15 | steps: 16 | - checkout 17 | - run: bundle install 18 | - run: bundle exec rake metrics:rubocop 19 | - run: bundle exec rake metrics:reek 20 | mutant: 21 | <<: *defaults 22 | steps: 23 | - checkout 24 | - run: bundle install 25 | - run: bundle exec mutant --zombie 'Anima*' 26 | workflows: 27 | version: 2 28 | test: 29 | jobs: 30 | - unit_specs 31 | - metrics 32 | - mutant 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | base: 7 | name: Base steps 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Check Whitespace 12 | run: git diff --check -- HEAD~1 13 | ruby-unit-spec: 14 | name: Unit Specs 15 | runs-on: ${{ matrix.os }} 16 | timeout-minutes: 5 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | ruby: ['2.6'] 21 | os: [ubuntu-latest] 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | - run: | 28 | gem install bundler 29 | bundle install 30 | - run: bundle exec rspec spec/unit 31 | ruby-integration-spec: 32 | name: Integration Specs 33 | runs-on: ${{ matrix.os }} 34 | timeout-minutes: 5 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | ruby: ['2.6'] 39 | os: [ubuntu-latest] 40 | steps: 41 | - uses: actions/checkout@v2 42 | - uses: actions/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby }} 45 | - run: | 46 | gem install bundler 47 | bundle install 48 | - run: bundle exec rspec spec/integration 49 | ruby-mutant: 50 | name: Mutation coverage 51 | runs-on: ${{ matrix.os }} 52 | timeout-minutes: 5 53 | strategy: 54 | fail-fast: false 55 | matrix: 56 | ruby: ['2.6'] 57 | os: [ubuntu-latest] 58 | steps: 59 | - uses: actions/checkout@v2 60 | with: 61 | fetch-depth: 0 62 | - uses: actions/setup-ruby@v1 63 | with: 64 | ruby-version: ${{ matrix.ruby }} 65 | - run: | 66 | gem install bundler 67 | bundle install 68 | - run: ./mutant.sh 69 | ruby-rubocop: 70 | name: Rubocop 71 | runs-on: ${{ matrix.os }} 72 | timeout-minutes: 5 73 | strategy: 74 | fail-fast: false 75 | matrix: 76 | ruby: ['2.6'] 77 | os: [ubuntu-latest] 78 | steps: 79 | - uses: actions/checkout@v2 80 | - uses: actions/setup-ruby@v1 81 | with: 82 | ruby-version: ${{ matrix.ruby }} 83 | - run: bundle install 84 | - run: bundle exec rubocop 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.rspec 2 | /Gemfile.lock 3 | /.rbx 4 | /.bundle 5 | /vendor 6 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Include: 3 | - 'Gemfile' 4 | Exclude: 5 | - 'Gemfile.devtools' 6 | - 'vendor/**/*' 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | bundler_args: --without yard guard benchmarks 4 | script: "bundle exec rake ci:metrics" 5 | rvm: 6 | - '2.1' 7 | - '2.2' 8 | matrix: 9 | allowed_failures: 10 | - jruby # Travis jruby does not run in >= 2.1 ruby yet 11 | - rbx # segfault 12 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # v0.3.2 2020-09-10 2 | 3 | * Change packaging to not rely on git. 4 | 5 | # v0.3.1 2018-02-15 6 | 7 | * Fix falsy key match detection 8 | 9 | # v0.3.0 2015-09-04 10 | 11 | Changes: 12 | 13 | * Drop support for ruby < 2.1 14 | * Drop support for Anima::Update 15 | * Add #with as a replacement that is active by default [#13] 16 | 17 | # v0.2.1 2014-04-10 18 | 19 | Changes: 20 | 21 | * Require ruby version >= 1.9.3 in gemspec. 22 | 23 | # v0.2.0 2014-01-13 24 | 25 | Breaking changes: 26 | 27 | * Remove AnimaInfectedClass.attributes_hash(instance) 28 | * Replace with AnimaInfectedClass#to_h 29 | 30 | # v0.1.1 2013-09-08 31 | 32 | * [change] Refactor internals. 33 | 34 | # v0.1.0 2013-09-08 35 | 36 | * [change] Update dependencies 37 | 38 | # v0.0.6 2013-02-18 39 | 40 | * [add] Support for updates via Anima::Update mixin 41 | * [change] Update dependencies 42 | 43 | # v0.0.5 2013-02-17 44 | 45 | * [changed] Update dependencies 46 | 47 | # v0.0.4 2013-01-21 48 | 49 | * [changed] Update dependencies 50 | 51 | # v0.0.3 2012-12-13 52 | 53 | * [changed] Use the attributes_hash naming consistently. 54 | 55 | # v0.0.2 2012-12-13 56 | 57 | First public release! 58 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'mutant' 8 | gem 'mutant-rspec' 9 | 10 | source 'https://oss:Px2ENN7S91OmWaD5G7MIQJi1dmtmYrEh@gem.mutant.dev' do 11 | gem 'mutant-license' 12 | end 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | anima 2 | ===== 3 | 4 | ![CI](https://github.com/mbj/anima/workflows/CI/badge.svg) 5 | 6 | Simple library to declare read only attributes on value-objects that are initialized via attributes hash. 7 | 8 | Installation 9 | ------------ 10 | 11 | Install the gem `anima` via your preferred method. 12 | 13 | Examples 14 | -------- 15 | 16 | ```ruby 17 | require 'anima' 18 | 19 | # Definition 20 | class Person 21 | include Anima.new(:salutation, :firstname, :lastname) 22 | end 23 | 24 | # Every day operation 25 | a = Person.new( 26 | salutation: 'Mr', 27 | firstname: 'Markus', 28 | lastname: 'Schirp' 29 | ) 30 | 31 | # Returns expected values 32 | a.salutation # => "Mr" 33 | a.firstname # => "Markus" 34 | a.lastname # => "Schirp" 35 | a.frozen? # => false 36 | 37 | b = Person.new( 38 | salutation: 'Mr', 39 | firstname: 'John', 40 | lastname: 'Doe' 41 | ) 42 | 43 | c = Person.new( 44 | salutation: 'Mr', 45 | firstname: 'Markus', 46 | lastname: 'Schirp' 47 | ) 48 | 49 | # Equality based on attributes 50 | a == b # => false 51 | a.eql?(b) # => false 52 | a.equal?(b) # => false 53 | 54 | a == c # => true 55 | a.eql?(c) # => true 56 | a.equal?(c) # => false 57 | 58 | # Functional-style updates 59 | d = b.with( 60 | salutation: 'Mrs', 61 | firstname: 'Sue', 62 | ) 63 | 64 | # It returns copies, no inplace modification 65 | d.equal?(b) # => false 66 | 67 | # Hash representation 68 | d.to_h # => { salutation: 'Mrs', firstname: 'Sue', lastname: 'Doe' } 69 | 70 | # Disallows initialization with incompatible attributes 71 | 72 | Person.new( 73 | # :saluatation key missing 74 | "firstname" => "Markus", # does NOT coerce this by intention 75 | :lastname => "Schirp" 76 | ) # raises Anima::Error with message "Person attributes missing: [:salutation, :firstname], unknown: ["firstname"] 77 | ``` 78 | 79 | Credits 80 | ------- 81 | 82 | * Markus Schirp 83 | 84 | Contributing 85 | ------------- 86 | 87 | * Fork the project. 88 | * Make your feature addition or bug fix. 89 | * Add tests for it. This is important so I don't break it in a 90 | future version unintentionally. 91 | * Commit, do not mess with Rakefile or version 92 | (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 93 | * Send me a pull request. Bonus points for topic branches. 94 | 95 | License 96 | ------- 97 | 98 | Copyright (c) 2013 Markus Schirp 99 | 100 | Permission is hereby granted, free of charge, to any person obtaining 101 | a copy of this software and associated documentation files (the 102 | "Software"), to deal in the Software without restriction, including 103 | without limitation the rights to use, copy, modify, merge, publish, 104 | distribute, sublicense, and/or sell copies of the Software, and to 105 | permit persons to whom the Software is furnished to do so, subject to 106 | the following conditions: 107 | 108 | The above copyright notice and this permission notice shall be 109 | included in all copies or substantial portions of the Software. 110 | 111 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 112 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 113 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 114 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 115 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 116 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 117 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 118 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'devtools' 2 | Devtools.init_rake_tasks 3 | 4 | Rake.application.load_imports 5 | task('metrics:mutant').clear 6 | 7 | namespace :metrics do 8 | task :mutant => :coverage do 9 | system(*%w[ 10 | bundle exec mutant 11 | --include lib 12 | --require anima 13 | --use rspec 14 | --zombie 15 | -- 16 | Anima* 17 | ]) or fail "Mutant task failed" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /anima.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'anima' 3 | s.version = '0.3.2' 4 | 5 | s.authors = ['Markus Schirp'] 6 | s.email = 'mbj@schirp-dso.com' 7 | s.summary = 'Initialize object attributes via attributes hash' 8 | s.homepage = 'http://github.com/mbj/anima' 9 | s.license = 'MIT' 10 | 11 | s.files = Dir.glob('lib/**/*') 12 | s.require_paths = %w(lib) 13 | s.extra_rdoc_files = %w(README.md) 14 | 15 | s.required_ruby_version = '>= 2.1.0' 16 | 17 | s.add_dependency('adamantium', '~> 0.2') 18 | s.add_dependency('equalizer', '~> 0.0.11') 19 | s.add_dependency('abstract_type', '~> 0.0.7') 20 | 21 | s.add_development_dependency('devtools', '~> 0.1.24') 22 | end 23 | -------------------------------------------------------------------------------- /config/flay.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 6 3 | total_score: 29 4 | -------------------------------------------------------------------------------- /config/flog.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 11.9 3 | -------------------------------------------------------------------------------- /config/mutant.yml: -------------------------------------------------------------------------------- 1 | --- 2 | integration: rspec 3 | -------------------------------------------------------------------------------- /config/reek.yml: -------------------------------------------------------------------------------- 1 | --- 2 | detectors: 3 | UncommunicativeParameterName: 4 | accept: [] 5 | exclude: [] 6 | enabled: true 7 | reject: 8 | - '/^.$/' 9 | - '/[0-9]$/' 10 | - '/[A-Z]/' 11 | TooManyMethods: 12 | max_methods: 9 13 | exclude: [] 14 | enabled: true 15 | UncommunicativeMethodName: 16 | accept: [] 17 | exclude: [] 18 | enabled: true 19 | reject: 20 | - '/^[a-z]$/' 21 | - '/[0-9]$/' 22 | - '/[A-Z]/' 23 | LongParameterList: 24 | max_params: 2 # TODO: decrease max_params to 2 25 | exclude: 26 | - Anima::Error#initialize # 3 params 27 | enabled: true 28 | overrides: {} 29 | FeatureEnvy: 30 | exclude: 31 | - Anima#attributes_hash 32 | enabled: true 33 | ClassVariable: 34 | exclude: [] 35 | enabled: true 36 | BooleanParameter: 37 | exclude: [] 38 | enabled: true 39 | # Buggy like hell 40 | IrresponsibleModule: 41 | exclude: [] 42 | enabled: false 43 | UncommunicativeModuleName: 44 | accept: [] 45 | exclude: [] 46 | enabled: true 47 | reject: 48 | - '/^.$/' 49 | - '/[0-9]$/' 50 | NestedIterators: 51 | ignore_iterators: [] 52 | exclude: 53 | - Anima::Attribute#define_reader # 2 levels 54 | - Anima#included # 2 levels 55 | enabled: true 56 | max_allowed_nesting: 1 57 | TooManyStatements: 58 | max_statements: 7 # TODO: decrease max_statements to 5 or less 59 | exclude: [] 60 | enabled: true 61 | DuplicateMethodCall: 62 | allow_calls: [] 63 | exclude: [] 64 | enabled: true 65 | max_calls: 1 66 | UtilityFunction: 67 | exclude: [] 68 | enabled: true 69 | Attribute: 70 | exclude: [] 71 | enabled: false 72 | UncommunicativeVariableName: 73 | accept: [] 74 | exclude: [] 75 | enabled: true 76 | reject: 77 | - '/^.$/' 78 | - '/[0-9]$/' 79 | - '/[A-Z]/' 80 | RepeatedConditional: 81 | exclude: [] 82 | enabled: true 83 | max_ifs: 1 84 | DataClump: 85 | exclude: [] 86 | enabled: true 87 | max_copies: 1 88 | min_clump_size: 3 89 | ControlParameter: 90 | exclude: [] 91 | enabled: true 92 | LongYieldList: 93 | max_params: 1 94 | exclude: [] 95 | enabled: true 96 | # Thats the whole point of this lib, oOo. 97 | ModuleInitialize: 98 | enabled: false 99 | -------------------------------------------------------------------------------- /config/rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ../.rubocop.yml 2 | 3 | # Avoid parameter lists longer than five parameters. 4 | ParameterLists: 5 | Max: 3 6 | CountKeywordArgs: true 7 | 8 | # Avoid more than `Max` levels of nesting. 9 | BlockNesting: 10 | Max: 3 11 | 12 | # Align with the style guide. 13 | CollectionMethods: 14 | PreferredMethods: 15 | collect: 'map' 16 | inject: 'reduce' 17 | find: 'detect' 18 | find_all: 'select' 19 | Exclude: 20 | # The Pathname#find method is mistakenly identified as Enumberable#find 21 | - lib/tasks/assets_precompile_nodigest.rake 22 | 23 | # Do not force public/protected/private keyword to be indented at the same 24 | # level as the def keyword. My personal preference is to outdent these keywords 25 | # because I think when scanning code it makes it easier to identify the 26 | # sections of code and visually separate them. When the keyword is at the same 27 | # level I think it sort of blends in with the def keywords and makes it harder 28 | # to scan the code and see where the sections are. 29 | AccessModifierIndentation: 30 | Enabled: false 31 | 32 | # Limit line length 33 | LineLength: 34 | Max: 79 35 | 36 | # Disable documentation checking until a class needs to be documented once 37 | Documentation: 38 | Enabled: false 39 | 40 | # Do not always use &&/|| instead of and/or. 41 | AndOr: 42 | Enabled: false 43 | 44 | # Do not favor modifier if/unless usage when you have a single-line body 45 | IfUnlessModifier: 46 | Enabled: false 47 | 48 | # Allow case equality operator (in limited use within the specs) 49 | CaseEquality: 50 | Enabled: false 51 | 52 | # Constants do not always have to use SCREAMING_SNAKE_CASE 53 | ConstantName: 54 | Enabled: false 55 | 56 | # Not all trivial readers/writers can be defined with attr_* methods 57 | TrivialAccessors: 58 | Enabled: false 59 | 60 | # Allow empty lines around class body 61 | EmptyLinesAroundClassBody: 62 | Enabled: false 63 | 64 | # Allow empty lines around module body 65 | EmptyLinesAroundModuleBody: 66 | Enabled: false 67 | 68 | # Allow empty lines around block body 69 | EmptyLinesAroundBlockBody: 70 | Enabled: false 71 | 72 | # Allow multiple line operations to not require indentation 73 | MultilineOperationIndentation: 74 | Enabled: false 75 | 76 | # Prefer String#% over Kernel#sprintf 77 | FormatString: 78 | Enabled: false 79 | 80 | # Use square brackets for literal Array objects 81 | PercentLiteralDelimiters: 82 | PreferredDelimiters: 83 | '%': '{}' 84 | '%i': '[]' 85 | '%q': () 86 | '%Q': () 87 | '%r': '{}' 88 | '%s': () 89 | '%w': '[]' 90 | '%W': '[]' 91 | '%x': () 92 | 93 | # Use %i[...] for arrays of symbols 94 | SymbolArray: 95 | Enabled: true 96 | 97 | # Align if/else blocks with the variable assignment 98 | EndAlignment: 99 | EnforcedStyleAlignWith: variable 100 | 101 | # Do not always align parameters when it is easier to read 102 | AlignParameters: 103 | Exclude: 104 | - spec/**/*_spec.rb 105 | 106 | # Prefer #kind_of? over #is_a? 107 | ClassCheck: 108 | EnforcedStyle: kind_of? 109 | 110 | # Do not prefer double quotes to be used when %q or %Q is more appropriate 111 | UnneededPercentQ: 112 | Enabled: false 113 | 114 | # Allow a maximum ABC score 115 | Metrics/AbcSize: 116 | Max: 21.02 117 | 118 | # Do not prefer lambda.call(...) over lambda.(...) 119 | LambdaCall: 120 | Enabled: false 121 | 122 | # Buggy cop, returns false positive for our code base 123 | NonLocalExitFromIterator: 124 | Enabled: false 125 | 126 | # To allow alignment of similar expressions we want to allow more than one 127 | # space around operators: 128 | # 129 | # let(:a) { bar + something } 130 | # let(:b) { foobar + something } 131 | # 132 | SpaceAroundOperators: 133 | Enabled: false 134 | 135 | # We use parallel assignments with great success 136 | ParallelAssignment: 137 | Enabled: false 138 | -------------------------------------------------------------------------------- /config/yardstick.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 100 3 | -------------------------------------------------------------------------------- /lib/anima.rb: -------------------------------------------------------------------------------- 1 | require 'adamantium' 2 | require 'equalizer' 3 | require 'abstract_type' 4 | 5 | # Main library namespace and mixin 6 | # @api private 7 | class Anima < Module 8 | include Adamantium::Flat, Equalizer.new(:attributes) 9 | 10 | # Return names 11 | # 12 | # @return [AttributeSet] 13 | attr_reader :attributes 14 | 15 | # Initialize object 16 | # 17 | # @return [undefined] 18 | def initialize(*names) 19 | @attributes = names.uniq.map(&Attribute.method(:new)).freeze 20 | end 21 | 22 | # Return new anima with attributes added 23 | # 24 | # @return [Anima] 25 | # 26 | # @example 27 | # anima = Anima.new(:foo) 28 | # anima.add(:bar) # equals Anima.new(:foo, :bar) 29 | # 30 | def add(*names) 31 | new(attribute_names + names) 32 | end 33 | 34 | # Return new anima with attributes removed 35 | # 36 | # @return [Anima] 37 | # 38 | # @example 39 | # anima = Anima.new(:foo, :bar) 40 | # anima.remove(:bar) # equals Anima.new(:foo) 41 | # 42 | def remove(*names) 43 | new(attribute_names - names) 44 | end 45 | 46 | # Return attributes hash for instance 47 | # 48 | # @param [Object] object 49 | # 50 | # @return [Hash] 51 | def attributes_hash(object) 52 | attributes.each_with_object({}) do |attribute, attributes_hash| 53 | attributes_hash[attribute.name] = attribute.get(object) 54 | end 55 | end 56 | 57 | # Return attribute names 58 | # 59 | # @return [Enumerable] 60 | def attribute_names 61 | attributes.map(&:name) 62 | end 63 | memoize :attribute_names 64 | 65 | # Initialize instance 66 | # 67 | # @param [Object] object 68 | # 69 | # @param [Hash] attribute_hash 70 | # 71 | # @return [self] 72 | def initialize_instance(object, attribute_hash) 73 | assert_known_attributes(object.class, attribute_hash) 74 | attributes.each do |attribute| 75 | attribute.load(object, attribute_hash) 76 | end 77 | self 78 | end 79 | 80 | # Static instance methods for anima infected classes 81 | module InstanceMethods 82 | # Initialize an anima infected object 83 | # 84 | # @param [#to_h] attributes 85 | # a hash that matches anima defined attributes 86 | # 87 | # @return [undefined] 88 | def initialize(attributes) 89 | self.class.anima.initialize_instance(self, attributes) 90 | end 91 | 92 | # Return a hash representation of an anima infected object 93 | # 94 | # @example 95 | # anima.to_h # => { :foo => : bar } 96 | # 97 | # @return [Hash] 98 | # 99 | # @api public 100 | def to_h 101 | self.class.anima.attributes_hash(self) 102 | end 103 | 104 | # Return updated instance 105 | # 106 | # @example 107 | # klass = Class.new do 108 | # include Anima.new(:foo, :bar) 109 | # end 110 | # 111 | # foo = klass.new(:foo => 1, :bar => 2) 112 | # updated = foo.with(:foo => 3) 113 | # updated.foo # => 3 114 | # updated.bar # => 2 115 | # 116 | # @param [Hash] attributes 117 | # 118 | # @return [Anima] 119 | # 120 | # @api public 121 | def with(attributes) 122 | self.class.new(to_h.update(attributes)) 123 | end 124 | end # InstanceMethods 125 | 126 | private 127 | 128 | # Infect the instance with anima 129 | # 130 | # @param [Class, Module] scope 131 | # 132 | # @return [undefined] 133 | def included(descendant) 134 | descendant.instance_exec(self, attribute_names) do |anima, names| 135 | # Define anima method 136 | define_singleton_method(:anima) { anima } 137 | 138 | # Define instance methods 139 | include InstanceMethods 140 | 141 | # Define attribute readers 142 | attr_reader(*names) 143 | 144 | # Define equalizer 145 | include Equalizer.new(*names) 146 | end 147 | end 148 | 149 | # Fail unless keys in +attribute_hash+ matches #attribute_names 150 | # 151 | # @param [Class] klass 152 | # the class being initialized 153 | # 154 | # @param [Hash] attribute_hash 155 | # the attributes to initialize +object+ with 156 | # 157 | # @return [undefined] 158 | # 159 | # @raise [Error] 160 | def assert_known_attributes(klass, attribute_hash) 161 | keys = attribute_hash.keys 162 | 163 | unknown = keys - attribute_names 164 | missing = attribute_names - keys 165 | 166 | unless unknown.empty? && missing.empty? 167 | fail Error.new(klass, missing, unknown) 168 | end 169 | end 170 | 171 | # Return new instance 172 | # 173 | # @param [Enumerable] attributes 174 | # 175 | # @return [Anima] 176 | def new(attributes) 177 | self.class.new(*attributes) 178 | end 179 | end # Anima 180 | 181 | require 'anima/error' 182 | require 'anima/attribute' 183 | -------------------------------------------------------------------------------- /lib/anima/attribute.rb: -------------------------------------------------------------------------------- 1 | class Anima 2 | # An attribute 3 | class Attribute 4 | include Adamantium::Flat, Equalizer.new(:name) 5 | 6 | # Initialize attribute 7 | # 8 | # @param [Symbol] name 9 | def initialize(name) 10 | @name, @instance_variable_name = name, :"@#{name}" 11 | end 12 | 13 | # Return attribute name 14 | # 15 | # @return [Symbol] 16 | attr_reader :name 17 | 18 | # Return instance variable name 19 | # 20 | # @return [Symbol] 21 | attr_reader :instance_variable_name 22 | 23 | # Load attribute 24 | # 25 | # @param [Object] object 26 | # @param [Hash] attributes 27 | # 28 | # @return [self] 29 | def load(object, attributes) 30 | set(object, attributes.fetch(name)) 31 | end 32 | 33 | # Get attribute value from object 34 | # 35 | # @param [Object] object 36 | # 37 | # @return [Object] 38 | def get(object) 39 | object.public_send(name) 40 | end 41 | 42 | # Set attribute value in object 43 | # 44 | # @param [Object] object 45 | # @param [Object] value 46 | # 47 | # @return [self] 48 | def set(object, value) 49 | object.instance_variable_set(instance_variable_name, value) 50 | 51 | self 52 | end 53 | end # Attribute 54 | end # Anima 55 | -------------------------------------------------------------------------------- /lib/anima/error.rb: -------------------------------------------------------------------------------- 1 | class Anima 2 | # Abstract base class for anima errors 3 | class Error < RuntimeError 4 | FORMAT = '%s attributes missing: %s, unknown: %s'.freeze 5 | private_constant(*constants(false)) 6 | 7 | # Initialize object 8 | # 9 | # @param [Class] klass 10 | # the class being initialized 11 | # @param [Enumerable] missing 12 | # @param [Enumerable] unknown 13 | # 14 | # @return [undefined] 15 | def initialize(klass, missing, unknown) 16 | super(FORMAT % [klass, missing, unknown]) 17 | end 18 | end # Error 19 | end # Anima 20 | -------------------------------------------------------------------------------- /mutant.sh: -------------------------------------------------------------------------------- 1 | #/usr/bin/bash -ex 2 | 3 | bundle exec mutant \ 4 | --zombie \ 5 | -- 'Anima*' 6 | -------------------------------------------------------------------------------- /spec/integration/simple_spec.rb: -------------------------------------------------------------------------------- 1 | class TestClass 2 | include Anima.new(:firstname, :lastname) 3 | end 4 | 5 | describe Anima, 'simple integration' do 6 | subject { TestClass.new(attributes) } 7 | 8 | context 'when instantiated with all attributes' do 9 | let(:attributes) do 10 | { 11 | firstname: 'Markus', 12 | lastname: 'Schirp' 13 | } 14 | end 15 | 16 | its(:firstname) { should eql('Markus') } 17 | its(:lastname) { should eql('Schirp') } 18 | end 19 | 20 | context 'with instantiated with extra attributes' do 21 | let(:attributes) do 22 | { 23 | firstname: 'Markus', 24 | lastname: 'Schirp', 25 | extra: 'Foo' 26 | } 27 | end 28 | 29 | it 'should raise error' do 30 | expect { subject }.to raise_error( 31 | Anima::Error, 32 | 'TestClass attributes missing: [], unknown: [:extra]' 33 | ) 34 | end 35 | end 36 | 37 | context 'when instantiated with missing attributes' do 38 | let(:attributes) { {} } 39 | 40 | it 'should raise error' do 41 | expect { subject }.to raise_error( 42 | Anima::Error, 43 | 'TestClass attributes missing: [:firstname, :lastname], unknown: []' 44 | ) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'anima' 2 | require 'devtools/spec_helper' 3 | -------------------------------------------------------------------------------- /spec/unit/anima/attribute_spec.rb: -------------------------------------------------------------------------------- 1 | describe Anima::Attribute do 2 | let(:object) { described_class.new(:foo) } 3 | 4 | describe '#get' do 5 | subject { object.get(target) } 6 | 7 | let(:target_class) do 8 | Class.new do 9 | attr_reader :foo 10 | 11 | def initialize(foo) 12 | @foo = foo 13 | end 14 | end 15 | end 16 | 17 | let(:target) { target_class.new(value) } 18 | let(:value) { double('Value') } 19 | 20 | it 'should return value' do 21 | should be(value) 22 | end 23 | end 24 | 25 | describe '#load' do 26 | subject { object.load(target, attribute_hash) } 27 | 28 | let(:target) { Object.new } 29 | let(:value) { double('Value') } 30 | let(:attribute_hash) { { foo: value } } 31 | 32 | it 'should set value as instance variable' do 33 | subject 34 | expect(target.instance_variable_get(:@foo)).to be(value) 35 | end 36 | 37 | it_should_behave_like 'a command method' 38 | end 39 | 40 | describe '#instance_variable_name' do 41 | subject { object.instance_variable_name } 42 | 43 | it { should be(:@foo) } 44 | 45 | it_should_behave_like 'an idempotent method' 46 | end 47 | 48 | describe '#set' do 49 | subject { object.set(target, value) } 50 | 51 | let(:target) { Object.new } 52 | 53 | let(:value) { double('Value') } 54 | 55 | it_should_behave_like 'a command method' 56 | 57 | it 'should set value as instance variable' do 58 | subject 59 | expect(target.instance_variable_get(:@foo)).to be(value) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/unit/anima/error_spec.rb: -------------------------------------------------------------------------------- 1 | describe Anima::Error do 2 | describe '#message' do 3 | let(:object) { described_class.new(Anima, missing, unknown) } 4 | 5 | let(:missing) { %i[missing] } 6 | let(:unknown) { %i[unknown] } 7 | 8 | subject { object.message } 9 | 10 | it 'should return the message string' do 11 | should eql('Anima attributes missing: [:missing], unknown: [:unknown]') 12 | end 13 | 14 | it_should_behave_like 'an idempotent method' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/unit/anima_spec.rb: -------------------------------------------------------------------------------- 1 | describe Anima do 2 | let(:object) { described_class.new(:foo) } 3 | 4 | describe '#attributes_hash' do 5 | let(:value) { double('Value') } 6 | let(:instance) { double(foo: value) } 7 | 8 | subject { object.attributes_hash(instance) } 9 | 10 | it { should eql(foo: value) } 11 | end 12 | 13 | describe '#remove' do 14 | let(:object) { described_class.new(:foo, :bar) } 15 | 16 | context 'with single attribute' do 17 | subject { object.remove(:bar) } 18 | 19 | it { should eql(described_class.new(:foo)) } 20 | end 21 | 22 | context 'with multiple attributes' do 23 | subject { object.remove(:foo, :bar) } 24 | 25 | it { should eql(described_class.new) } 26 | end 27 | 28 | context 'with inexisting attribute' do 29 | subject { object.remove(:baz) } 30 | 31 | it { should eql(object) } 32 | end 33 | end 34 | 35 | describe '#add' do 36 | context 'with single attribute' do 37 | subject { object.add(:bar) } 38 | 39 | it { should eql(described_class.new(:foo, :bar)) } 40 | end 41 | 42 | context 'with multiple attributes' do 43 | subject { object.add(:bar, :baz) } 44 | 45 | it { should eql(described_class.new(:foo, :bar, :baz)) } 46 | end 47 | 48 | context 'with duplicate attribute ' do 49 | subject { object.add(:foo) } 50 | 51 | it { should eql(object) } 52 | end 53 | end 54 | 55 | describe '#attributes' do 56 | subject { object.attributes } 57 | 58 | it { should eql([Anima::Attribute.new(:foo)]) } 59 | it { should be_frozen } 60 | end 61 | 62 | describe '#included' do 63 | let(:target) do 64 | object = self.object 65 | Class.new do 66 | include object 67 | end 68 | end 69 | 70 | let(:value) { double('Value') } 71 | let(:instance) { target.new(foo: value) } 72 | let(:instance_b) { target.new(foo: value) } 73 | let(:instance_c) { target.new(foo: double('Bar')) } 74 | 75 | context 'on instance' do 76 | subject { instance } 77 | 78 | its(:foo) { should be(value) } 79 | 80 | it { should eql(instance_b) } 81 | it { should_not eql(instance_c) } 82 | end 83 | 84 | context 'on singleton' do 85 | subject { target } 86 | 87 | it 'should define attribute hash reader' do 88 | expect(instance.to_h).to eql(foo: value) 89 | end 90 | 91 | its(:anima) { should be(object) } 92 | end 93 | end 94 | 95 | describe '#initialize_instance' do 96 | let(:object) { Anima.new(:foo, :bar) } 97 | let(:target) { Object.new } 98 | let(:foo) { double('Foo') } 99 | let(:bar) { double('Bar') } 100 | 101 | subject { object.initialize_instance(target, attribute_hash) } 102 | 103 | context 'when all keys are present in attribute hash' do 104 | let(:attribute_hash) { { foo: foo, bar: bar } } 105 | 106 | it 'should initialize target instance variables' do 107 | subject 108 | 109 | expect( 110 | target 111 | .instance_variables 112 | .map(&:to_sym) 113 | .to_set 114 | ).to eql(%i[@foo @bar].to_set) 115 | expect(target.instance_variable_get(:@foo)).to be(foo) 116 | expect(target.instance_variable_get(:@bar)).to be(bar) 117 | end 118 | 119 | it_should_behave_like 'a command method' 120 | end 121 | 122 | context 'when an extra key is present in attribute hash' do 123 | let(:attribute_hash) { { foo: foo, bar: bar, baz: double('Baz') } } 124 | 125 | it 'should raise error' do 126 | expect { subject }.to raise_error( 127 | Anima::Error, 128 | Anima::Error.new(target.class, [], [:baz]).message 129 | ) 130 | end 131 | 132 | context 'and the extra key is falsy' do 133 | let(:attribute_hash) { { foo: foo, bar: bar, nil => double('Baz') } } 134 | 135 | it 'should raise error' do 136 | expect { subject }.to raise_error( 137 | Anima::Error, 138 | Anima::Error.new(target.class, [], [nil]).message 139 | ) 140 | end 141 | end 142 | end 143 | 144 | context 'when a key is missing in attribute hash' do 145 | let(:attribute_hash) { { bar: bar } } 146 | 147 | it 'should raise error' do 148 | expect { subject }.to raise_error( 149 | Anima::Error.new(target.class, [:foo], []).message 150 | ) 151 | end 152 | end 153 | end 154 | 155 | describe 'using super in initialize' do 156 | subject { klass.new } 157 | 158 | let(:klass) do 159 | Class.new do 160 | include Anima.new(:foo) 161 | def initialize(attributes = { foo: :bar }) 162 | super 163 | end 164 | end 165 | end 166 | 167 | its(:foo) { should eql(:bar) } 168 | end 169 | 170 | describe '#to_h on an anima infected instance' do 171 | subject { instance.to_h } 172 | 173 | let(:instance) { klass.new(params) } 174 | let(:params) { Hash[foo: :bar] } 175 | let(:klass) do 176 | Class.new do 177 | include Anima.new(:foo) 178 | end 179 | end 180 | 181 | it { should eql(params) } 182 | end 183 | 184 | describe '#with' do 185 | subject { object.with(attributes) } 186 | 187 | let(:klass) do 188 | Class.new do 189 | include Anima.new(:foo, :bar) 190 | end 191 | end 192 | 193 | let(:object) { klass.new(foo: 1, bar: 2) } 194 | 195 | context 'with empty attributes' do 196 | let(:attributes) { {} } 197 | 198 | it { should eql(object) } 199 | end 200 | 201 | context 'with updated attribute' do 202 | let(:attributes) { { foo: 3 } } 203 | 204 | it { should eql(klass.new(foo: 3, bar: 2)) } 205 | end 206 | end 207 | end 208 | --------------------------------------------------------------------------------