├── .circleci └── config.yml ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── scheduled_cci.yml ├── .gitignore ├── .ruby-gemset ├── .ruby-version ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.rdoc ├── Rakefile ├── build-matrix.json ├── immutable-struct.gemspec ├── lib └── immutable-struct.rb ├── owners.json └── spec ├── immutable_struct_spec.rb └── spec_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY - this is managed by Git Reduce in goro and generated from build-matrix.json 2 | # 3 | --- 4 | version: 2.1 5 | 6 | ############ 7 | ## Github Actions Pipeline Params 8 | ############ 9 | 10 | parameters: 11 | GHA_Event: 12 | type: string 13 | default: "" 14 | GHA_Actor: 15 | type: string 16 | default: "" 17 | GHA_Action: 18 | type: string 19 | default: "" 20 | GHA_Meta: 21 | type: string 22 | default: "" 23 | 24 | jobs: 25 | generate-and-push-docs: 26 | docker: 27 | - image: cimg/ruby:3.3.8 28 | auth: 29 | username: "$DOCKERHUB_USERNAME" 30 | password: "$DOCKERHUB_PASSWORD" 31 | steps: 32 | - checkout 33 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 34 | - run: bundle install 35 | - run: 36 | name: Generate documentation 37 | command: ' if [[ $(bundle exec rake -T docs:generate:custom) ]]; then echo 38 | "Generating docs using rake task docs:generate:custom" ; bundle exec rake 39 | docs:generate:custom ; elif [[ $(bundle exec rake -T docs:generate) ]]; 40 | then echo "Generating docs using rake task docs:generate" ; bundle exec 41 | rake docs:generate ; else echo "Skipping doc generation" ; exit 0 ; fi ' 42 | - run: 43 | name: Push documentation to Unwritten 44 | command: if [[ $(bundle exec rake -T docs:push) ]]; then bundle exec rake 45 | docs:push; fi 46 | release: 47 | docker: 48 | - image: cimg/ruby:3.3.8 49 | auth: 50 | username: "$DOCKERHUB_USERNAME" 51 | password: "$DOCKERHUB_PASSWORD" 52 | steps: 53 | - checkout 54 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 55 | - run: bundle install 56 | - run: 57 | name: Artifactory login 58 | command: mkdir -p ~/.gem && curl -u$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN https://stitchfix01.jfrog.io/stitchfix01/api/gems/eng-gems/api/v1/api_key.yaml 59 | > ~/.gem/credentials && chmod 0600 ~/.gem/credentials 60 | - run: 61 | name: Build/release gem to artifactory 62 | command: bundle exec rake push_artifactory 63 | ruby-3_3_8: 64 | docker: 65 | - image: cimg/ruby:3.3.8 66 | auth: 67 | username: "$DOCKERHUB_USERNAME" 68 | password: "$DOCKERHUB_PASSWORD" 69 | working_directory: "~/immutable-struct" 70 | steps: 71 | - checkout 72 | - run: 73 | name: Check for Gemfile.lock presence 74 | command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see 75 | https://github.com/stitchfix/eng-wiki/blob/main/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)" 76 | 1>&2 ; exit 1 ; else exit 0 ; fi ' 77 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 78 | - run: bundle install 79 | - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml 80 | --format=doc 81 | - run: 82 | name: Run Additional CI Steps 83 | command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps; 84 | fi 85 | - run: 86 | name: Notify Pager Duty 87 | command: bundle exec y-notify "#app-platform-ops" 88 | when: on_fail 89 | - store_test_results: 90 | path: "/tmp/test-results" 91 | ruby-3_2_8: 92 | docker: 93 | - image: cimg/ruby:3.2.8 94 | auth: 95 | username: "$DOCKERHUB_USERNAME" 96 | password: "$DOCKERHUB_PASSWORD" 97 | working_directory: "~/immutable-struct" 98 | steps: 99 | - checkout 100 | - run: 101 | name: Check for Gemfile.lock presence 102 | command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see 103 | https://github.com/stitchfix/eng-wiki/blob/main/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)" 104 | 1>&2 ; exit 1 ; else exit 0 ; fi ' 105 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 106 | - run: bundle install 107 | - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml 108 | --format=doc 109 | - run: 110 | name: Run Additional CI Steps 111 | command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps; 112 | fi 113 | - run: 114 | name: Notify Pager Duty 115 | command: bundle exec y-notify "#app-platform-ops" 116 | when: on_fail 117 | - store_test_results: 118 | path: "/tmp/test-results" 119 | workflows: 120 | version: 2 121 | on-commit: 122 | unless: 123 | equal: [ "schedule", << pipeline.parameters.GHA_Event >> ] 124 | jobs: 125 | - release: 126 | context: org-global 127 | requires: 128 | - ruby-3_3_8 129 | - ruby-3_2_8 130 | filters: 131 | tags: 132 | only: "/^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:(-|\\.)(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$/" 133 | branches: 134 | ignore: /.*/ 135 | - generate-and-push-docs: 136 | context: org-global 137 | requires: 138 | - release 139 | filters: 140 | tags: 141 | only: "/^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:(-|\\.)(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$/" 142 | branches: 143 | ignore: /.*/ 144 | - ruby-3_3_8: 145 | context: org-global 146 | filters: 147 | tags: 148 | only: &1 /.*/ 149 | - ruby-3_2_8: 150 | context: org-global 151 | filters: 152 | tags: 153 | only: *1 154 | scheduled: 155 | when: 156 | equal: [ "schedule", << pipeline.parameters.GHA_Event >> ] 157 | jobs: 158 | - ruby-3_3_8: 159 | context: org-global 160 | - ruby-3_2_8: 161 | context: org-global 162 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # THIS FILE IS NOT THE SOURCE OF TRUTH for app ownership. If this app 2 | # is transferred to another team, the app ownership should be changed 3 | # in fixops. 4 | # 5 | # View current canonical app ownership on Unwritten: 6 | # https://unwritten.stitchfix.com/docs/applications-and-services 7 | # 8 | # This file uses the GitHub CODEOWNERS convention to assign PR reviewers: 9 | # https://help.github.com/articles/about-codeowners/ 10 | 11 | * @stitchfix/app-platform -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Problem 2 | 3 | «Brief overview of the problem» 4 | 5 | ## Solution 6 | 7 | «Brief description of how you solved the problem» 8 | -------------------------------------------------------------------------------- /.github/workflows/scheduled_cci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: '4 21 * * 1,2,3,4,5' 4 | workflow_dispatch: 5 | 6 | jobs: 7 | trigger-circleci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: CircleCI trigger on schedule 11 | id: step1 12 | uses: CircleCI-Public/trigger-circleci-pipeline-action@v1.0.5 13 | env: 14 | CCI_TOKEN: ${{ secrets.CCI_TOKEN || secrets.CCI_TOKEN_FOR_PUBLIC_REPOS }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | InstalledFiles 7 | _yardoc 8 | coverage 9 | doc/ 10 | lib/bundler/man 11 | pkg 12 | rdoc 13 | spec/reports 14 | spec/examples.txt 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | html 19 | .*.sw? 20 | Gemfile.lock 21 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | immutable-struct 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.2.8 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to keeping our community open and inclusive. 4 | 5 | **Our Code of Conduct can be found here**: 6 | http://opensource.stitchfix.com/code-of-conduct.html 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Thanks for using and improving *ImmutableStruct*! If you'd like to help out, check out [the project's issues list](https://github.com/stitchfix/immutable-struct/issues) for ideas on what could be improved. If there's an idea you'd like to propose, or a design change, feel free to file a new issue or send a pull request: 3 | 4 | 1. [Fork][fork] the repo. 5 | 1. [Create a topic branch.][branch] 6 | 1. Write tests. 7 | 1. Implement your feature or fix bug. 8 | 1. Add, commit, and push your changes. 9 | 1. [Submit a pull request.][pr] 10 | 11 | [fork]: https://help.github.com/articles/fork-a-repo/ 12 | [branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 13 | [pr]: https://help.github.com/articles/using-pull-requests/ 14 | 15 | ## General Guidelines 16 | 17 | * When in doubt, test it. If you can't test it, re-think what you are doing. 18 | * Code formatting and internal application architecture should be consistent. 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in immutable-struct.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Dave Copeland 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = ImmutableStruct 2 | 3 | {Build Status}[https://travis-ci.org/stitchfix/immutable-struct] 4 | 5 | Creates struct-like classes (that can build value objects) that do not have setters and also have better constructors than Ruby's built-in +Struct+. 6 | 7 | This is highly useful for creating presenters, non-database-related models, or other quick and dirty classes in your application. Instead of using a +Hash+ or +OpenStruct+, you can create a bit more clarity around your types by using +ImmutableStruct+, which is almost as convienient. 8 | 9 | == Install 10 | 11 | Add to your +Gemfile+: 12 | 13 | gem 'immutable-struct' 14 | 15 | Then install: 16 | 17 | bundle install 18 | 19 | If not using bundler, just use RubyGems: 20 | 21 | gem install immutable-struct 22 | 23 | 24 | == To use 25 | 26 | Person = ImmutableStruct.new(:name, :age, :job, :active?, [:addresses]) do 27 | def minor? 28 | age < 18 29 | end 30 | end 31 | 32 | p = Person.new(name: "Dave", # name will be 'Dave' 33 | age: 40, # age will be 40 34 | # job is omitted, so will be nil 35 | active: true) # active and active? will be true 36 | # addresses is omitted, but since we've selected 37 | # Array coercion, it'll be [] 38 | p.name # => "Dave" 39 | p.age # => 40 40 | p.active? # => true 41 | p.minor? # => false 42 | p.addresses # => [] 43 | 44 | p2 = Person.new(name: "Dave", age: 40, active: true) 45 | 46 | p == p2 # => true 47 | p.eql?(p2) # => true 48 | 49 | SimilarPerson = ImmutableStruct.new(:name, :age, :job, :active?, [:addresses]) 50 | 51 | sp = SimilarPerson.new(name: "Dave", age: 40, active: true) 52 | 53 | p == sp # => false # Different class leads to inequality 54 | 55 | new_person = p.merge(name: "Other Dave", age: 41) # returns a new object with merged attributes 56 | new_person.name # => "Other Dave" 57 | new_person.age # => 41 58 | new_person.active? # => true 59 | 60 | You can coerce values into struct types by using the +from+ method. 61 | This is similar to Ruby's conversion functions, e.g. Integer("1"). 62 | 63 | dave = Person.from(p) 64 | dave.equal?(p) # => true (object equality) 65 | 66 | daveish = Person.from(dave.to_h) 67 | daveish.equal?(dave) # => false 68 | daveish == dave # => true 69 | 70 | You can treat the interior of the block as a normal class definition with the exception of setting constants. 71 | Use +const_set+ to scope constants as-expected. 72 | 73 | Point = ImmutableStruct.new(:x, :y) do 74 | const_set(:ZERO, 0) 75 | ONE_HUNDRED = 100 76 | end 77 | Point::ZERO # => 0 78 | ::ONE_HUNDRED # => 100 79 | ::ZERO # => NameError: uninitialized constant ZERO 80 | 81 | 82 | == Links 83 | 84 | * rdoc[http://stitchfix.github.io/immutable-struct] 85 | * source[http://github.com/stitchfix/immutable-struct] 86 | * blog[http://technology.stitchfix.com/blog/2013/12/20/presenters-delegation-vs-structs/] 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "fileutils" 3 | 4 | include FileUtils 5 | 6 | require "rspec/core/rake_task" 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | require "rdoc/task" 10 | RDoc::Task.new do |rdoc| 11 | rdoc.main = "README.rdoc" 12 | rdoc.rdoc_files.include("README.rdoc", "lib/*.rb") 13 | end 14 | 15 | def sh!(command) 16 | sh command do |ok,res| 17 | fail "Problem running '#{command}'" unless ok 18 | end 19 | end 20 | 21 | task :publish_rdoc do 22 | rm_rf "tmp" 23 | mkdir_p "tmp" 24 | chdir "tmp" do 25 | sh! "git clone git@github.com:stitchfix/immutable-struct.git" 26 | chdir "immutable-struct" do 27 | sh! "git checkout gh-pages" 28 | `rm -rf *` 29 | end 30 | end 31 | Rake::Task["rdoc"].invoke 32 | `cp -R html/* tmp/immutable-struct` 33 | chdir "tmp/immutable-struct" do 34 | sh! "git add -A ." 35 | sh! "git commit -m 'updated rdoc'" 36 | sh! "git push origin gh-pages" 37 | end 38 | end 39 | 40 | task :default => :spec 41 | 42 | 43 | -------------------------------------------------------------------------------- /build-matrix.json: -------------------------------------------------------------------------------- 1 | { 2 | "build_matrix": {} 3 | } 4 | -------------------------------------------------------------------------------- /immutable-struct.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'immutable-struct' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "immutable-struct" 8 | spec.version = ImmutableStruct::VERSION 9 | spec.authors = ["Stitch Fix Engineering","Dave Copeland","Simeon Willbanks"] 10 | spec.email = ["opensource@stitchfix.com","davetron5000@gmail.com","simeon@simeons.net"] 11 | spec.description = %q{Easily create value objects without the pain of Ruby's Struct (or its setters)} 12 | spec.summary = %q{Easily create value objects without the pain of Ruby's Struct (or its setters)} 13 | spec.homepage = "https://github.com/stitchfix/immutable-struct" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "> 1.3" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "rspec" 24 | spec.add_development_dependency('rspec_junit_formatter') 25 | end 26 | -------------------------------------------------------------------------------- /lib/immutable-struct.rb: -------------------------------------------------------------------------------- 1 | # Creates classes for value objects/read-only records. Most useful 2 | # when creating model objects for concepts not stored in the database. 3 | # 4 | # This will create a class that has attr_readers for all given attributes, as 5 | # well as a hash-based constructor. Further, the block given to with_attributes 6 | # will be evaluated as if it were inside a class definition, allowing you 7 | # to add methods, include or extend modules, or do whatever else you want. 8 | class ImmutableStruct 9 | VERSION='2.5.0' #:nodoc: 10 | # Create a new class with the given read-only attributes. 11 | # 12 | # attributes:: list of symbols or strings that can be used to create attributes. 13 | # Any attribute with a question mark in it (e.g. +:foo?+) will create 14 | # an attribute without a question mark that passes through the raw 15 | # value and an attribute *with* the question mark that coerces that 16 | # value to a boolean. You would initialize it with the non-question-mark value 17 | # An attribute that is an array of one symbol will create an attribute named for 18 | # that symbol, but that doesn't return nil, instead returning the +to_a+ of the 19 | # value passed to the construtor. 20 | # block:: if present, evaluates in the context of the new class, so +def+, +def.self+, +include+ 21 | # and +extend+ should all work as in a normal class definition. 22 | # 23 | # Example: 24 | # 25 | # Person = ImmutableStruct.new(:name, :location, :minor?, [:aliases]) 26 | # 27 | # p = Person.new(name: 'Dave', location: Location.new("DC"), minor: false) 28 | # p.name # => 'Dave' 29 | # p.location # => 30 | # p.minor # => false 31 | # p.minor? # => false 32 | # 33 | # p = Person.new(name: 'Rudy', minor: "yup") 34 | # p.name # => 'Rudy' 35 | # p.location # => nil 36 | # p.minor # => "yup" 37 | # p.minor? # => true 38 | # 39 | # new_person = p.merge(name: "Other Dave", age: 41) # returns a new object with merged attributes 40 | # new_person.name # => "Other Dave" 41 | # new_person.age # => 41 42 | # new_person.active? # => true 43 | # 44 | # Note that you also get an implementation of `to_h` that will include **all** no-arg methods in its 45 | # output: 46 | # 47 | # Person = ImmutableStruct.new(:name, :location, :minor?, [:aliases]) 48 | # p = Person.new(name: 'Dave', minor: "yup", aliases: [ "davetron", "davetron5000" ]) 49 | # p.to_h # => { name: "Dave", minor: "yup", minor?: true, aliases: ["davetron", "davetron5000" ] } 50 | # 51 | # This has two subtle side-effects: 52 | # 53 | # * Methods that take no args, but are not 'attributes' will get called by `to_h`. This shouldn't be a 54 | # problem, because you should not generally be doing this on a struct-like class. 55 | # * Methods that take no args, but call `to_h` will stack overflow. This is because the class' 56 | # internals have no way to know about this. This is particularly a problem if you want to 57 | # define your own `to_json` method that serializes the result of `to_h`. 58 | # 59 | def self.new(*attributes,&block) 60 | klass = Class.new do 61 | attributes.each do |attribute| 62 | if attribute.to_s =~ /(^.*)\?$/ 63 | raw_name = $1 64 | attr_reader raw_name 65 | define_method(attribute) do 66 | !!instance_variable_get("@#{raw_name}") 67 | end 68 | elsif attribute.kind_of?(Array) and attribute.size == 1 69 | attr_reader attribute[0] 70 | else 71 | attr_reader attribute 72 | end 73 | end 74 | 75 | def self.from(value) 76 | case value 77 | when self then value 78 | when Hash then new(value) 79 | else 80 | raise ArgumentError, "cannot coerce #{value.class} #{value.inspect} into #{self}" 81 | end 82 | end 83 | 84 | define_method(:initialize) do |*args| 85 | attrs = args[0] || {} 86 | attributes.each do |attribute| 87 | if attribute.kind_of?(Array) and attribute.size == 1 88 | ivar_name = attribute[0].to_s 89 | instance_variable_set("@#{ivar_name}", (attrs[ivar_name.to_s] || attrs[ivar_name.to_sym]).to_a) 90 | else 91 | ivar_name = attribute.to_s.gsub(/\?$/,'') 92 | attr_value = attrs[ivar_name.to_s].nil? ? attrs[ivar_name.to_sym] : attrs[ivar_name.to_s] 93 | instance_variable_set("@#{ivar_name}", attr_value) 94 | end 95 | end 96 | end 97 | 98 | define_method(:==) do |other| 99 | return false unless other.is_a?(klass) 100 | attributes.all? do |attribute| 101 | if attribute.kind_of?(Array) and attribute.size == 1 102 | attribute = attribute[0].to_s 103 | end 104 | self.send(attribute) == other.send(attribute) 105 | end 106 | end 107 | 108 | def merge(new_attrs) 109 | attrs = to_h 110 | self.class.new(attrs.merge(new_attrs)) 111 | end 112 | 113 | alias_method :eql?, :== 114 | 115 | define_method(:hash) do 116 | attribute_values = attributes.map do |attribute| 117 | if attribute.kind_of?(Array) and attribute.size == 1 118 | attribute = attribute[0].to_s 119 | end 120 | self.send(attribute) 121 | end 122 | (attribute_values + [self.class]).hash 123 | end 124 | 125 | def deconstruct_keys(keys) 126 | if keys 127 | to_h.slice(*keys) 128 | else 129 | to_h 130 | end 131 | end 132 | end 133 | klass.class_exec(&block) unless block.nil? 134 | 135 | imethods = klass.instance_methods(include_super=false).map { |method_name| 136 | klass.instance_method(method_name) 137 | }.reject { |method| 138 | method.arity != 0 139 | }.map(&:name).map(&:to_sym) 140 | 141 | klass.class_exec(imethods) do |imethods| 142 | define_method(:to_h) do 143 | imethods.inject({}) do |hash, method| 144 | next hash if [:==, :eql?, :merge, :hash].include?(method) 145 | hash.merge(method.to_sym => self.send(method)) 146 | end 147 | end 148 | alias_method :to_hash, :to_h 149 | end 150 | klass 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /owners.json: -------------------------------------------------------------------------------- 1 | { 2 | "owners": [ 3 | { 4 | "team": "dev-platform" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /spec/immutable_struct_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | require 'json' 3 | require 'set' 4 | 5 | module TestModule 6 | def hello; "hello"; end 7 | end 8 | 9 | describe ImmutableStruct do 10 | describe "construction" do 11 | context "with non-boolean attributes and no body" do 12 | before do 13 | @klass = ImmutableStruct.new(:foo, :bar, :baz) 14 | end 15 | subject { @klass.new } 16 | 17 | it { is_expected.to respond_to(:foo) } 18 | it { is_expected.to respond_to(:bar) } 19 | it { is_expected.to respond_to(:baz) } 20 | it { is_expected.not_to respond_to(:foo=) } 21 | it { is_expected.not_to respond_to(:bar=) } 22 | it { is_expected.not_to respond_to(:baz=) } 23 | it { is_expected.not_to respond_to(:foo?) } 24 | it { is_expected.not_to respond_to(:bar?) } 25 | it { is_expected.not_to respond_to(:baz?) } 26 | 27 | context "instances can be created with a hash" do 28 | context 'with symbol keys' do 29 | subject { @klass.new(foo: "FOO", bar: 42, baz: [:a,:b,:c]) } 30 | 31 | it { expect(subject.foo).to eq("FOO") } 32 | it { expect(subject.bar).to eq(42) } 33 | it { expect(subject.baz).to eq([:a,:b,:c]) } 34 | end 35 | 36 | context "with string keys" do 37 | subject { ImmutableStruct.new(:foo) } 38 | 39 | it { expect(subject.new('foo' => true).foo).to eq(true) } 40 | it { expect(subject.new('foo' => false).foo).to eq(false) } 41 | end 42 | end 43 | end 44 | 45 | context "intelligently handles boolean attributes" do 46 | subject { ImmutableStruct.new(:foo?) } 47 | 48 | context "with boolean values" do 49 | it { expect(subject.new(foo: false).foo?).to eq(false) } 50 | it { expect(subject.new(foo: false).foo).to eq(false) } 51 | it { expect(subject.new(foo: true).foo?).to eq(true) } 52 | it { expect(subject.new(foo: true).foo).to eq(true) } 53 | end 54 | 55 | context "with falsey, non-boolean values" do 56 | it { expect(subject.new.foo?).to eq(false) } 57 | it { expect(subject.new.foo).to eq(nil) } 58 | end 59 | 60 | context "with truthy, non-boolean values" do 61 | it { expect(subject.new(foo: "true").foo?).to eq(true) } 62 | it { expect(subject.new(foo: "true").foo).to eq("true") } 63 | end 64 | end 65 | 66 | context "allows for values that should be coerced to collections" do 67 | it "can define an array value that should never be nil" do 68 | klass = ImmutableStruct.new([:foo], :bar) 69 | instance = klass.new 70 | expect(instance.foo).to eq([]) 71 | expect(instance.bar).to eq(nil) 72 | end 73 | end 74 | 75 | it "allows defining instance methods" do 76 | klass = ImmutableStruct.new(:foo, :bar) do 77 | def derived; self.foo + ":" + self.bar; end 78 | end 79 | instance = klass.new(foo: "hello", bar: "world") 80 | expect(instance.derived).to eq("hello:world") 81 | end 82 | 83 | it "allows defining class methods" do 84 | klass = ImmutableStruct.new(:foo, :bar) do 85 | def self.from_array(array) 86 | new(foo: array[0], bar: array[1]) 87 | end 88 | end 89 | instance = klass.from_array(["hello","world"]) 90 | expect(instance.foo).to eq("hello") 91 | expect(instance.bar).to eq("world") 92 | end 93 | 94 | it "allows module inclusion" do 95 | klass = ImmutableStruct.new(:foo) do 96 | include TestModule 97 | end 98 | instance = klass.new 99 | 100 | expect(instance).to respond_to(:hello) 101 | expect(klass).not_to respond_to(:hello) 102 | end 103 | 104 | it "allows module extension" do 105 | klass = ImmutableStruct.new(:foo) do 106 | extend TestModule 107 | end 108 | instance = klass.new 109 | 110 | expect(instance).not_to respond_to(:hello) 111 | expect(klass).to respond_to(:hello) 112 | end 113 | end 114 | 115 | describe "coercion" do 116 | let(:klass) { ImmutableStruct.new(:lolwat) } 117 | 118 | it "is a noop when value is already the defined type" do 119 | value = klass.new 120 | new_value = klass.from(value) 121 | expect(new_value).to be(value) 122 | end 123 | 124 | it "initializes a new value when Hash is given" do 125 | value = klass.from(lolwat: "haha") 126 | expect(value.lolwat).to eq("haha") 127 | end 128 | 129 | it "errors when value cannot be coerced" do 130 | expect { klass.from(Object.new) } 131 | .to raise_error(ArgumentError) 132 | end 133 | end 134 | 135 | describe "to_h" do 136 | context "vanilla struct with just derived values" do 137 | it "should include the output of params and block methods in the hash" do 138 | klass = ImmutableStruct.new(:name, :minor?, :location, [:aliases]) do 139 | def nick_name 140 | 'bob' 141 | end 142 | end 143 | instance = klass.new(name: "Rudy", minor: "ayup", aliases: [ "Rudyard", "Roozoola" ]) 144 | expect(instance.to_h).to eq({ 145 | name: "Rudy", 146 | minor: "ayup", 147 | minor?: true, 148 | location: nil, 149 | aliases: [ "Rudyard", "Roozoola"], 150 | nick_name: "bob", 151 | }) 152 | end 153 | end 154 | 155 | context "additional method that takes arguments" do 156 | it "should not call the additional method" do 157 | klass = ImmutableStruct.new(:name, :minor?, :location, [:aliases]) do 158 | def nick_name 159 | 'bob' 160 | end 161 | def location_near?(other_location) 162 | false 163 | end 164 | end 165 | instance = klass.new(name: "Rudy", minor: "ayup", aliases: [ "Rudyard", "Roozoola" ]) 166 | expect(instance.to_h).to eq({ 167 | name: "Rudy", 168 | minor: "ayup", 169 | minor?: true, 170 | location: nil, 171 | aliases: [ "Rudyard", "Roozoola"], 172 | nick_name: "bob", 173 | }) 174 | end 175 | end 176 | 177 | context "to_hash is its alias" do 178 | it "is identical" do 179 | klass = ImmutableStruct.new(:name, :minor?, :location, [:aliases]) do 180 | def nick_name 181 | 'bob' 182 | end 183 | def location_near?(other_location) 184 | false 185 | end 186 | end 187 | instance = klass.new(name: "Rudy", minor: "ayup", aliases: [ "Rudyard", "Roozoola" ]) 188 | expect(instance.to_h).to eq(instance.to_hash) 189 | end 190 | end 191 | end 192 | 193 | describe "#deconstruct_keys" do 194 | it "returns a hash with the specified keys" do 195 | klass = ImmutableStruct.new(:a, :b, :c) 196 | instance = klass.new(a: 1, b: 2, c: 3) 197 | expect(instance.deconstruct_keys([:a, :c])).to eq({ a: 1, c: 3 }) 198 | end 199 | 200 | context "when keys are specified that do not exist in the struct" do 201 | it 'returns only the keys that do exist' do 202 | klass = ImmutableStruct.new(:a, :b, :c) 203 | instance = klass.new(a: 1, b: 2, c: 3) 204 | expect(instance.deconstruct_keys([:a, :foo])).to eq({ a: 1 }) 205 | end 206 | end 207 | 208 | it "allows an instance to be used with pattern matching" do 209 | klass = ImmutableStruct.new(:a, :b, :c) 210 | instance = klass.new(a: 1, b: 2, c: 3) 211 | expect { 212 | case instance 213 | in { a: 1 } 214 | # good! 215 | end 216 | # a NoMatchingPatternError would be raised if the pattern didn't match 217 | }.not_to raise_error 218 | end 219 | 220 | context "pattern matching against 'any key'" do 221 | it 'returns all properties' do 222 | klass = ImmutableStruct.new(:a, :b, :c) 223 | instance = klass.new(a: 1, b: 2, c: 3) 224 | case instance 225 | in **rest 226 | expect(rest).to eq({ a: 1, b: 2, c: 3 }) 227 | end 228 | # a NoMatchingPatternError would be raised if the pattern didn't match 229 | end 230 | end 231 | end 232 | 233 | describe "merge" do 234 | it "returns a new object as a result of merging attributes" do 235 | klass = ImmutableStruct.new(:food, :snacks, :butter) 236 | instance = klass.new(food: 'hot dogs', butter: true) 237 | new_instance = instance.merge(snacks: 'candy hot dogs', butter: false) 238 | 239 | expect(instance.food).to eq('hot dogs') 240 | expect(instance.butter).to eq(true) 241 | expect(instance.snacks).to eq(nil) 242 | 243 | expect(new_instance.food).to eq('hot dogs') 244 | expect(new_instance.snacks).to eq('candy hot dogs') 245 | expect(new_instance.butter).to eq(false) 246 | 247 | expect(new_instance.object_id).not_to eq(instance.object_id) 248 | end 249 | end 250 | 251 | describe "equality" do 252 | before do 253 | klass_1 = ImmutableStruct.new(:foo, [:bars]) 254 | klass_2 = ImmutableStruct.new(:foo, [:bars]) 255 | @k1_a = klass_1.new(foo: 'foo', bars: ['bar', 'baz']) 256 | @k1_b = klass_1.new(foo: 'xxx', bars: ['yyy']) 257 | @k1_c = klass_1.new(foo: 'foo', bars: ['bar', 'baz']) 258 | @k2_a = klass_2.new(foo: 'foo', bars: ['bar']) 259 | end 260 | 261 | describe "==" do 262 | it "should be equal to itself" do 263 | expect(@k1_a == @k1_a).to be true 264 | end 265 | 266 | it "should be equal to same class with identical attribute values" do 267 | expect(@k1_a == @k1_c).to be true 268 | end 269 | 270 | it 'should not be equal to same class with different attribute values' do 271 | expect(@k1_a == @k1_b).to be false 272 | end 273 | 274 | it 'should not be equal to different class with identical attribute values' do 275 | expect(@k1_a == @k3_a).to be false 276 | end 277 | end 278 | 279 | describe "eql?" do 280 | it "should be equal to itself" do 281 | expect(@k1_a.eql?(@k1_a)).to be true 282 | end 283 | 284 | it "should be equal to same class with identical attribute values" do 285 | expect(@k1_a.eql?(@k1_c)).to be true 286 | end 287 | 288 | it 'should not be equal to same class with different attribute values' do 289 | expect(@k1_a.eql?(@k1_b)).to be false 290 | end 291 | 292 | it 'should not be equal to different class with identical attribute values' do 293 | expect(@k1_a.eql?(@k3_a)).to be false 294 | end 295 | end 296 | 297 | describe "hash" do 298 | it "should have same hash value as itself" do 299 | expect(@k1_a.hash.eql?(@k1_a.hash)).to be true 300 | end 301 | 302 | it "should have same hash value as same class with identical attribute values" do 303 | expect(@k1_a.hash.eql?(@k1_c.hash)).to be true 304 | end 305 | 306 | it 'should not have hash value as same class with different attribute values' do 307 | expect(@k1_a.hash.eql?(@k1_b.hash)).to be false 308 | end 309 | 310 | it 'should not have hash value equal to different class with identical attribute values' do 311 | expect(@k1_a.hash.eql?(@k3_a.hash)).to be false 312 | end 313 | 314 | it 'should reject set addition if same instance is already a member' do 315 | set = Set.new([@k1_a]) 316 | expect(set.add?(@k1_a)).to be nil 317 | end 318 | 319 | it 'should reject set addition if different instance, but attributes are the same' do 320 | set = Set.new([@k1_a]) 321 | expect(set.add?(@k1_c)).to be nil 322 | end 323 | 324 | it 'should allow set addition if different instance and attribute values' do 325 | set = Set.new([@k1_a]) 326 | expect(set.add?(@k1_b)).not_to be nil 327 | end 328 | 329 | it 'should allow set addition if different class' do 330 | set = Set.new([@k1_a]) 331 | expect(set.add?(@k2_a)).not_to be nil 332 | end 333 | end 334 | end 335 | end 336 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | GEM_ROOT = File.expand_path(File.join(File.dirname(__FILE__),'..')) 2 | Dir["#{GEM_ROOT}/spec/support/**/*.rb"].sort.each {|f| require f} 3 | 4 | require 'immutable-struct' 5 | 6 | RSpec.configure do |config| 7 | config.expect_with :rspec do |c| 8 | c.syntax = [:expect] 9 | end 10 | 11 | config.mock_with :rspec do |mocks| 12 | mocks.verify_partial_doubles = true 13 | end 14 | 15 | config.order = :random 16 | Kernel.srand config.seed 17 | 18 | config.example_status_persistence_file_path = "spec/examples.txt" 19 | end 20 | --------------------------------------------------------------------------------