├── .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 | {
}[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 |
--------------------------------------------------------------------------------