invariable 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c53305cea17a18d1408e1a877638284b336815349579758e185c01a9a5cc344
4
+ data.tar.gz: aeb9efc0033747f03a137d39c118e5d961e6cca2c805b4736043edf37b7ae5ad
5
+ SHA512:
6
+ metadata.gz: 437de015755448224c646e7e4a7cd07063658e75181442a437018869d40ee845eabd9f89823b7d6cefdcb3f0483193f0437ff19d1760184a76385e5089826fc2
7
+ data.tar.gz: a4a18c103fcfa25451c7be911640f870e236f782385283099829580fc3373b667c9a1c2e21fbcfcbd03d4ce3319c7c3b81520fa14abc0a3c38a96d5c05bc0cde
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ tmp/
2
+ pkg/
3
+ local/
4
+ doc/
5
+ .yardoc/
6
+ gems.locked
data/.yardopts ADDED
@@ -0,0 +1,6 @@
1
+ --readme README.md
2
+ --title 'tcp-client Documentation'
3
+ --charset utf-8
4
+ --markup markdown
5
+ lib/**/*.rb - LICENSE
6
+
data/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2017-2021, Mike Blumtritt. All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Invariable
2
+
3
+ An Invariable bundles a number of read-only attributes.
4
+ It can be used like a Hash as well as an Array. It supports subclassing and pattern matching.
5
+
6
+ An Invariable can be created explicitly as a Class like a Struct. Or existing classes can easily be extended to an Invariable.
7
+
8
+ - Gem: [rubygems.org](https://rubygems.org/gems/invariable)
9
+ - Source: [github.com](https://github.com/mblumtritt/invariable)
10
+ - Help: [rubydoc.info](https://rubydoc.info/github/mblumtritt/invariable/main/index)
11
+
12
+ ## Sample
13
+
14
+ ```ruby
15
+ require 'invariable'
16
+
17
+ class Person
18
+ include Invariable
19
+ attributes :name, :last_name
20
+ attribute address: Invariable.new(:city, :zip, :street)
21
+
22
+ def full_name
23
+ "#{name} #{last_name}"
24
+ end
25
+ end
26
+ ...
27
+ john = Person.new(name: 'John', last_name: 'Doe')
28
+ john.full_name #=> "John Doe"
29
+ john.address.city #=> nil
30
+ john = john.update(
31
+ address: { street: '123 Main St', city: 'Anytown', zip: '45678' }
32
+ )
33
+ john.dig(:address, :city) #=> "Anytown"
34
+
35
+ ```
36
+
37
+ For more samples see [the samples dir](https://github.com/mblumtritt/invariable/tree/main/samples)
38
+
39
+ ## Installation
40
+
41
+ Use [Bundler](http://gembundler.com/) to use TCPClient in your own project:
42
+
43
+ Add to your `Gemfile`:
44
+
45
+ ```ruby
46
+ gem 'invariable'
47
+ ```
48
+
49
+ and install it by running Bundler:
50
+
51
+ ```bash
52
+ bundle
53
+ ```
54
+
55
+ To install the gem globally use:
56
+
57
+ ```bash
58
+ gem install invariable
59
+ ```
60
+
61
+ After that you need only a single line of code in your project to have all tools on board:
62
+
63
+ ```ruby
64
+ require 'invariable'
65
+ ```
data/gems.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './lib/invariable/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'invariable'
7
+ spec.version = Invariable::VERSION
8
+ spec.required_ruby_version = '>= 2.7.0'
9
+
10
+ spec.author = 'Mike Blumtritt'
11
+ spec.summary = 'The Invariable data class for Ruby.'
12
+ spec.description = <<~description
13
+ An Invariable bundles a number of read-only attributes.
14
+ It can be used like a Hash as well as an Array.
15
+ It supports subclassing and pattern matching.
16
+ description
17
+
18
+ spec.homepage = 'https://github.com/mblumtritt/invariable'
19
+ spec.license = 'BSD-3-Clause'
20
+ spec.metadata.merge!(
21
+ 'source_code_uri' => 'https://github.com/mblumtritt/invariable',
22
+ 'bug_tracker_uri' => 'https://github.com/mblumtritt/invariable/issues',
23
+ 'documentation_uri' => 'https://rubydoc.info/github/mblumtritt/invariable'
24
+ )
25
+
26
+ spec.add_development_dependency 'bundler'
27
+ spec.add_development_dependency 'rake'
28
+ spec.add_development_dependency 'rspec'
29
+ spec.add_development_dependency 'yard'
30
+
31
+ all_files = Dir.chdir(__dir__) { `git ls-files -z`.split(0.chr) }
32
+ spec.test_files = all_files.grep(%r{^spec/})
33
+ spec.files = all_files - spec.test_files
34
+ spec.extra_rdoc_files = %w[README.md LICENSE]
35
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invariable
4
+ # current version number
5
+ VERSION = '0.1.0'
6
+ end
data/lib/invariable.rb ADDED
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # An Invariable bundles a number of read-only attributes.
5
+ # It can be used like a Hash as well as an Array. It supports subclassing
6
+ # and pattern matching.
7
+ #
8
+ # An Invariable can be created explicitly as a Class like a Struct. Or existing
9
+ # classes can easily be extended to an Invariable.
10
+ #
11
+ # @example
12
+ # class Person
13
+ # include Invariable
14
+ # attributes :name, :last_name
15
+ # attribute address: Invariable.new(:city, :zip, :street)
16
+ #
17
+ # def full_name
18
+ # "#{name} #{last_name}"
19
+ # end
20
+ # end
21
+ # ...
22
+ # john = Person.new(name: 'John', last_name: 'Doe')
23
+ # john.full_name #=> "John Doe"
24
+ # john.address.city #=> nil
25
+ # john = john.update(
26
+ # address: { street: '123 Main St', city: 'Anytown', zip: '45678' }
27
+ # )
28
+ # john.dig(:address, :city) #=> "Anytown"
29
+ #
30
+ module Invariable
31
+ class << self
32
+ #
33
+ # @!attribute [r] members
34
+ # @return [Array<Symbol>] all attribute names of this class
35
+ #
36
+ # @!method attributes(*names, **defaults)
37
+ # Defines new attributes
38
+ # @param names [Array<Symbol>] attribute names
39
+ # @param defaults [Hash<Symbol,Object|Class>] attribute names with default
40
+ # values
41
+ # @return [Array<Symbols>] names of defined attributes
42
+ #
43
+ # @!method member?(name)
44
+ # @return [Boolean] wheter the given name is a valid attribute name for
45
+ # this class
46
+ #
47
+
48
+ #
49
+ # Creates a new class with the given attribute names. It also allows to
50
+ # specify default values which are used when an instance is created.
51
+ #
52
+ # With an optional block the class can be extended.
53
+ #
54
+ # @overload new(*names, **defaults, &block)
55
+ # @example create a simple User class
56
+ # User = Invariable.new(:name, :last_name)
57
+ # User.members #=> [:name, :last_name]
58
+ #
59
+ # @example create a User class with a default value
60
+ # User = Invariable.new(:name, :last_name, processed: false)
61
+ # User.new(name: 'John', last_name: 'Doe').to_h
62
+ # #=> {:name=>"John", :last_name=>"Doe", :processed=>false}
63
+ #
64
+ # @example create a User class with an additional method
65
+ # User = Invariable.new(:name, :last_name) do
66
+ # def full_name
67
+ # "#{name} #{last_name}"
68
+ # end
69
+ # end
70
+ # User.new(name: 'John', last_name: 'Doe').full_name
71
+ # #=> "John Doe"
72
+ #
73
+ # @overload new(base_class, *names, **defaults, &block)
74
+ # @example create a Person class derived from a User class
75
+ # User = Invariable.new(:name, :last_name)
76
+ # Person = Invariable.new(User, :city, :zip, :street)
77
+ # Person.members #=> [:name, :last_name, :city, :zip, :street]
78
+ #
79
+ # @param names [Array<Symbol>] attribute names
80
+ # @param defaults [Hash<Symbol,Object|Class>] attribute names with default
81
+ # values
82
+ # @yieldparam new_class [Class] the created class
83
+ #
84
+ # @return [Class] the created class
85
+ #
86
+ def new(*names, **defaults, &block)
87
+ Class.new(names.first.is_a?(Class) ? names.shift : Object) do
88
+ include(Invariable)
89
+ attributes(*names, **defaults)
90
+ class_eval(&block) if block
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def included(base)
97
+ base.extend(InvariableClassMethods)
98
+ end
99
+ end
100
+
101
+ #
102
+ # Initializes a new instance with the given `attributes` Hash.
103
+ #
104
+ # @return [Invariable] itself
105
+ #
106
+ def initialize(attributes = nil)
107
+ super()
108
+ attributes ||= {}.compare_by_identity
109
+ @__attr__ = {}
110
+ self
111
+ .class
112
+ .instance_variable_get(:@__attr__)
113
+ .each_pair do |key, default|
114
+ @__attr__[key] =
115
+ if default.is_a?(Class)
116
+ default.new(attributes[key]).freeze
117
+ elsif attributes.key?(key)
118
+ attributes[key]
119
+ else
120
+ default
121
+ end
122
+ end
123
+ end
124
+
125
+ #
126
+ # Compares attributes of itself with the attributes of a given other Object.
127
+ #
128
+ # This means that the given object needs to implement the same attributes and
129
+ # all it's attribute values have to be equal.
130
+ #
131
+ # @return [Boolean] wheter the attribute values are equal
132
+ #
133
+ def ==(other)
134
+ @__attr__.each_pair do |k, v|
135
+ return false if !other.respond_to?(k) || (v != other.__send__(k))
136
+ end
137
+ true
138
+ end
139
+
140
+ #
141
+ # Returns the value of the given attribute or the attribute at the given
142
+ # index.
143
+ #
144
+ # @overload [](name)
145
+ # @param name [Symbol] the name of the attribute
146
+ #
147
+ # @overload [](index)
148
+ # @param index [Integer] the index of the attribute
149
+ #
150
+ # @return [Object] the attribute value
151
+ #
152
+ # @raise [NameError] if the named attribute does not exist
153
+ # @raise [IndexError] if the index is out of bounds
154
+ #
155
+ def [](arg)
156
+ return @__attr__[arg] if @__attr__.key?(arg)
157
+ raise(NameError, "not member - #{arg}", caller) unless Integer === arg
158
+ if arg >= @__attr__.size || arg < -@__attr__.size
159
+ raise(IndexError, "invalid offset - #{arg}")
160
+ end
161
+ @__attr__.values[arg]
162
+ end
163
+
164
+ # @!visibility private
165
+ def deconstruct_keys(...)
166
+ @__attr__.deconstruct_keys(...)
167
+ end
168
+
169
+ #
170
+ # Finds and returns the object in nested objects that is specified by the
171
+ # identifiers. The nested objects may be instances of various classes.
172
+ #
173
+ # @param identifiers [Array<Symbol,Integer>] one or more identifiers or
174
+ # indices
175
+ #
176
+ # @return [Object] object found
177
+ # @return [nil] if nothing was found
178
+ #
179
+ def dig(*identifiers)
180
+ (Integer === identifiers.first ? @__attr__.values : @__attr__).dig(
181
+ *identifiers
182
+ )
183
+ end
184
+
185
+ #
186
+ # @overload each(&block)
187
+ # Yields the value of each attribute in order.
188
+ #
189
+ # @yieldparam value [Object] attribute value
190
+ # @return [Invariable] itself
191
+ #
192
+ # @overload each
193
+ # Creates an Enumerator about its attribute values.
194
+ #
195
+ # @return [Enumerator]
196
+ #
197
+ def each(&block)
198
+ return to_enum(__method__) unless block
199
+ @__attr__.each_value(&block)
200
+ self
201
+ end
202
+
203
+ #
204
+ # @overload each_pair(&block)
205
+ # Yields the name and value of each attribute in order.
206
+ #
207
+ # @yieldparam name [Symbol] attribute name
208
+ # @yieldparam value [Object] attribute value
209
+ # @return [Invariable] itself
210
+ #
211
+ # @overload each
212
+ # Creates an Enumerator about its attribute name/values pairs.
213
+ #
214
+ # @return [Enumerator]
215
+ #
216
+ def each_pair(&block)
217
+ return to_enum(__method__) unless block
218
+ @__attr__.each_pair(&block)
219
+ self
220
+ end
221
+
222
+ #
223
+ # Compares its class and all attributes of itself with the class and
224
+ # attributes of a given other Object.
225
+ #
226
+ # @return [Boolean] wheter the classes and each attribute value are equal
227
+ #
228
+ # @see ==
229
+ #
230
+ def eql?(other)
231
+ self.class == other.class && self == other
232
+ end
233
+
234
+ # @!visibility private
235
+ def hash
236
+ (to_a << self.class).hash
237
+ end
238
+
239
+ #
240
+ # @return [String] description of itself as a string
241
+ #
242
+ def inspect
243
+ attributes = @__attr__.map { |k, v| "#{k}: #{v.inspect}" }
244
+ "<#{self.class}::#{__id__} #{attributes.join(', ')}>"
245
+ end
246
+ alias to_s inspect
247
+
248
+ #
249
+ # @return [Boolean] wheter the given name is a valid attribute name
250
+ #
251
+ def member?(name)
252
+ @__attr__.key?(name)
253
+ end
254
+ alias key? member?
255
+
256
+ #
257
+ # @attribute [r] members
258
+ # @return [Array<Symbol>] all attribute names
259
+ #
260
+ def members
261
+ @__attr__.keys
262
+ end
263
+
264
+ #
265
+ # @attribute [r] size
266
+ # @return [Integer] number of attributes
267
+ #
268
+ def size
269
+ @__attr__.size
270
+ end
271
+
272
+ #
273
+ # @return [Array<Object>] the values of all attributes
274
+ #
275
+ def to_a
276
+ @__attr__.values
277
+ end
278
+ alias values to_a
279
+
280
+ # @!visibility private
281
+ def deconstruct
282
+ @__attr__.values
283
+ end
284
+
285
+ #
286
+ # @overload to_h
287
+ # @return [Hash<Symbol,Object>] names and values of all attributes
288
+ #
289
+ # @overload to_h(compact: true)
290
+ # @return [Hash<Symbol,Object>] names and values of all attributes which
291
+ # are not `nil` and which are not empty Invariable results
292
+ #
293
+ # @overload to_h(&block)
294
+ # Returns a Hash containing the results of the block on each pair of the
295
+ # receiver as pairs.
296
+ # @yieldparam [Symbol] name the attribute name
297
+ # @yieldparam [Object] value the attribute value
298
+ # @yieldreturn [Array<Symbol,Object>] the pair to be stored in the result
299
+ #
300
+ # @return [Hash<Object,Object>] pairs returned by the `block`
301
+ #
302
+ def to_h(compact: false, &block)
303
+ return to_compact_h if compact
304
+ return Hash[@__attr__.map(&block)] if block
305
+ @__attr__.transform_values { |v| v.is_a?(Invariable) ? v.to_h : v }
306
+ end
307
+
308
+ #
309
+ # Updates all given attributes.
310
+ #
311
+ # @return [Invariable] a new updated instance of itself
312
+ def update(attributes)
313
+ opts = {}
314
+ @__attr__.each_pair do |k, v|
315
+ opts[k] = attributes.key?(k) ? attributes[k] : v
316
+ end
317
+ self.class.new(opts)
318
+ end
319
+
320
+ #
321
+ # @return [Array<Object>] Array whose elements are the atttributes of self at
322
+ # the given Integer indexes
323
+ def values_at(...)
324
+ @__attr__.values.values_at(...)
325
+ end
326
+
327
+ private
328
+
329
+ def to_compact_h
330
+ result = {}
331
+ @__attr__.each_pair do |key, value|
332
+ next if value.nil?
333
+ next result[key] = value unless value.is_a?(Invariable)
334
+ value = value.to_h(compact: true)
335
+ result[key] = value unless value.empty?
336
+ end
337
+ result
338
+ end
339
+
340
+ module InvariableClassMethods
341
+ # @!visibility private
342
+ def attributes(*names, **defaults)
343
+ @__attr__ = __attr__init unless defined?(@__attr__)
344
+ (names + defaults.keys).map do |name|
345
+ __attr__define(name, defaults[name])
346
+ end
347
+ end
348
+ alias attribute attributes
349
+
350
+ # @!visibility private
351
+ def members
352
+ @__attr__.keys
353
+ end
354
+
355
+ # @!visibility private
356
+ def member?(name)
357
+ @__attr__.key?(name)
358
+ end
359
+
360
+ private
361
+
362
+ def __attr__define(name, default)
363
+ unless name.respond_to?(:to_sym)
364
+ raise(TypeError, "invalid attribute name type - #{name}", caller(4))
365
+ end
366
+ name = name.to_sym
367
+ if method_defined?(name)
368
+ raise(NameError, "attribute already defined - #{name}", caller(4))
369
+ end
370
+ define_method(name) { @__attr__[name] }
371
+ @__attr__[name] = default.is_a?(Class) ? default : default.dup.freeze
372
+ name
373
+ end
374
+
375
+ def __attr__init
376
+ if superclass.instance_variable_defined?(:@__attr__)
377
+ Hash[superclass.instance_variable_get(:@__attr__)]
378
+ else
379
+ {}.compare_by_identity
380
+ end
381
+ end
382
+ end
383
+ private_constant(:InvariableClassMethods)
384
+ end
data/rakefile.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake/clean'
4
+ require 'bundler/gem_tasks'
5
+ require 'rspec/core/rake_task'
6
+ require 'yard'
7
+
8
+ $stdout.sync = $stderr.sync = true
9
+ CLOBBER << 'prj' << '.yardoc' << 'doc'
10
+ task(:default) { exec('rake --tasks') }
11
+ RSpec::Core::RakeTask.new { |task| task.ruby_opts = %w[-w] }
12
+ YARD::Rake::YardocTask.new { |task| task.stats_options = %w[--list-undoc] }
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Sample to use Invariables as complex options.
4
+ # See the options used for Net::HTTP.start to refer the sample.
5
+ #
6
+
7
+ require_relative '../lib/invariable'
8
+
9
+ #
10
+ # HTTP Options
11
+ # HTTPOptions#to_h is used to generate the options Hash provided to
12
+ # Net::HTTP.start (see there).
13
+ #
14
+ class HTTPOptions
15
+ include Invariable
16
+
17
+ attributes :open_timeout,
18
+ :read_timeout,
19
+ :write_timeout,
20
+ :continue_timeout,
21
+ :keep_alive_timeout,
22
+ :close_on_empty_response
23
+
24
+ attribute proxy: Invariable.new(:from_env, :address, :port, :user, :pass)
25
+
26
+ attribute ssl:
27
+ Invariable.new(
28
+ :ca_file,
29
+ :ca_path,
30
+ :cert,
31
+ :cert_store,
32
+ :ciphers,
33
+ :extra_chain_cert,
34
+ :key,
35
+ :timeout,
36
+ :version,
37
+ :min_version,
38
+ :max_version,
39
+ :verify_callback,
40
+ :verify_depth,
41
+ :verify_mode,
42
+ :verify_hostname
43
+ )
44
+
45
+ #
46
+ # Superseded to add some magic and to flatten the Hash provided to eg.
47
+ # Net::HTTP.start.
48
+ #
49
+ def to_h
50
+ # the compact option allows to skip all values of nil and all empty
51
+ # Invariable values
52
+ result = super(compact: true)
53
+
54
+ # flatten the SSL options:
55
+ ssl = result.delete(:ssl)
56
+ if ssl
57
+ # prefix two options:
58
+ ssl[:ssl_timeout] = ssl.delete(:timeout) if ssl.key?(:timeout)
59
+ ssl[:ssl_version] = ssl.delete(:version) if ssl.key?(:version)
60
+ result.merge!(ssl)
61
+
62
+ # automagic :)
63
+ result[:use_ssl] = true
64
+ end
65
+
66
+ # flatten the proxy options and prefix the keys
67
+ proxy = result.delete(:proxy)
68
+ if proxy
69
+ result.merge!(proxy.transform_keys! { |key| "proxy_#{key}".to_sym })
70
+ end
71
+
72
+ result
73
+ end
74
+ end
75
+
76
+ puts '- create a sample'
77
+ sample =
78
+ HTTPOptions.new(
79
+ open_timeout: 2,
80
+ read_timeout: 2,
81
+ write_timeout: 2,
82
+ ssl: {
83
+ timeout: 2,
84
+ min_version: :TLS1_2
85
+ },
86
+ proxy: {
87
+ from_env: true
88
+ }
89
+ )
90
+ p sample.to_h
data/samples/person.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # This sample shows the different aspects of Invariable.
4
+ #
5
+
6
+ require_relative '../lib/invariable'
7
+
8
+ #
9
+ # Person is a sample class which is combined from primitives as well as an
10
+ # anonymous Invariable class used for the address attribute.
11
+ #
12
+ class Person
13
+ include Invariable
14
+ attributes :name, :last_name, address: Invariable.new(:city, :zip, :street)
15
+
16
+ def full_name
17
+ "#{name} #{last_name}"
18
+ end
19
+
20
+ def to_s
21
+ address.to_a.unshift(full_name).compact.join(', ')
22
+ end
23
+ end
24
+
25
+ puts '- we can check the members of the class'
26
+ p Person.members #=> [:name, :last_name, :address]
27
+ p Person.member?(:last_name) #=> true
28
+
29
+ puts '- create a person record'
30
+ john = Person.new(name: 'John', last_name: 'Doe')
31
+ puts john #=> "John Doe"
32
+
33
+ puts '- we can check the members of the instance'
34
+ p john.members #=> [:name, :last_name, :address]
35
+ p john.member?(:last_name) #=> true
36
+
37
+ puts '- the address members are nil'
38
+ p john.address.city #=> nil
39
+
40
+ puts '- converted to an compact Hash the address is skipped'
41
+ p john.to_h(compact: true) #=> {:name=>"John", :last_name=>"Doe"}
42
+
43
+ puts '- update the record with an address'
44
+ john =
45
+ john.update(address: { street: '123 Main St', city: 'Anytown', zip: '45678' })
46
+
47
+ puts '- the city is assigned now'
48
+ p john.dig(:address, :city) #=> "Anytown"
49
+ puts john #=> John Doe, Anytown, 45678, 123 Main St
data/spec/helper.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require_relative '../lib/invariable'
5
+
6
+ $stdout.sync = $stderr.sync = true
7
+
8
+ RSpec.configure do |config|
9
+ config.disable_monkey_patching!
10
+ config.warnings = true
11
+ config.order = :random
12
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'helper'
3
+
4
+ RSpec.describe 'include Invariable' do
5
+ let(:invariable) do
6
+ Class.new do
7
+ include Invariable
8
+ attribute :name
9
+ attribute :last_name
10
+ end
11
+ end
12
+
13
+ it 'defines all attributes' do
14
+ expect(invariable.members).to eq %i[name last_name]
15
+ end
16
+
17
+ it 'initializes the attributes' do
18
+ instance = invariable.new(name: 'John', last_name: 'Doe')
19
+ expect(instance.name).to eq 'John'
20
+ expect(instance.last_name).to eq 'Doe'
21
+ end
22
+
23
+ it 'initializes only given attributes' do
24
+ instance = invariable.new(last_name: 'Doe')
25
+ expect(instance.name).to be_nil
26
+ expect(instance.last_name).to eq 'Doe'
27
+ end
28
+
29
+ it 'ignores unknown attributes' do
30
+ expect {
31
+ invariable.new(foo: 42, last_name: 'Doe', ignored: true)
32
+ }.not_to raise_error
33
+ end
34
+
35
+ context 'when defining an already defined attribute' do
36
+ it 'raises an exception' do
37
+ expect do
38
+ Class.new do
39
+ include Invariable
40
+ attribute :name
41
+ attribute :name
42
+ end
43
+ end.to raise_error(NameError, 'attribute already defined - name')
44
+ end
45
+ end
46
+
47
+ context 'when used in sub-classing' do
48
+ let(:invariable) { Class.new(base_class) { attributes :street, :city } }
49
+ let(:base_class) do
50
+ Class.new do
51
+ include Invariable
52
+ attributes :name, :last_name
53
+ end
54
+ end
55
+
56
+ it 'defines all attributes' do
57
+ expect(invariable.members).to eq %i[name last_name street city]
58
+ end
59
+
60
+ it 'initializes the attributes' do
61
+ instance =
62
+ invariable.new(
63
+ name: 'John',
64
+ last_name: 'Doe',
65
+ street: '123 Main St',
66
+ city: 'Anytown'
67
+ )
68
+
69
+ expect(instance.name).to eq 'John'
70
+ expect(instance.last_name).to eq 'Doe'
71
+ expect(instance.street).to eq '123 Main St'
72
+ expect(instance.city).to eq 'Anytown'
73
+ end
74
+
75
+ it 'initializes only given attributes' do
76
+ instance = invariable.new(last_name: 'Doe', city: 'Anytown')
77
+ expect(instance.name).to be_nil
78
+ expect(instance.last_name).to eq 'Doe'
79
+ expect(instance.street).to be_nil
80
+ expect(instance.city).to eq 'Anytown'
81
+ end
82
+
83
+ it 'ignores unknown attributes' do
84
+ expect {
85
+ invariable.new(foo: 42, city: 'Anytown', ignored: true)
86
+ }.not_to raise_error
87
+ end
88
+
89
+ context 'when defining an already defined attribute of the superclass' do
90
+ it 'raises an exception' do
91
+ expect do
92
+ Class.new(base_class){ attribute :name }
93
+ end.to raise_error(NameError, 'attribute already defined - name')
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'helper'
3
+
4
+ RSpec.describe 'Invariable.new' do
5
+ context 'when only attribute names are given' do
6
+ subject(:invariable) { Invariable.new(:name, :last_name) }
7
+
8
+ it 'creates a new Class' do
9
+ expect(invariable).to be_a Class
10
+ end
11
+
12
+ it 'is inherited from Object' do
13
+ expect(invariable).to be < Object
14
+ end
15
+
16
+ it 'defines the attributes as instance methods' do
17
+ expect(invariable).to be_public_method_defined :name
18
+ expect(invariable).to be_public_method_defined :last_name
19
+ end
20
+ end
21
+
22
+ context 'when a base class and attribute names are given' do
23
+ subject(:invariable) { Invariable.new(foo_class, :name, :last_name) }
24
+ let(:foo_class) { Class.new }
25
+
26
+ it 'creates a new Class' do
27
+ expect(invariable).to be_a Class
28
+ end
29
+
30
+ it 'is inherited from the given class' do
31
+ expect(invariable).to be < foo_class
32
+ end
33
+
34
+ it 'defines the attributes as instance methods' do
35
+ expect(invariable).to be_public_method_defined :name
36
+ expect(invariable).to be_public_method_defined :last_name
37
+ end
38
+ end
39
+
40
+ context 'when a block is given' do
41
+ subject(:invariable) do
42
+ Invariable.new(:name, :last_name) do
43
+ def full_name
44
+ "#{name} #{last_name}"
45
+ end
46
+ end
47
+ end
48
+
49
+ it 'allows to extend the new class' do
50
+ expect(invariable).to be_public_method_defined :full_name
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'helper'
3
+
4
+ RSpec.describe Invariable do
5
+ subject(:instance) do
6
+ sample_class.new(
7
+ name: 'John',
8
+ last_name: 'Doe',
9
+ address: {
10
+ zip: '45678',
11
+ city: 'Anytown',
12
+ street: '123 Main St'
13
+ }
14
+ )
15
+ end
16
+
17
+ let(:sample_class) do
18
+ Class.new do
19
+ include Invariable
20
+ attributes :name, :last_name
21
+ attribute address: Invariable.new(:city, :zip, :street)
22
+
23
+ def full_name
24
+ "#{name} #{last_name}"
25
+ end
26
+ end
27
+ end
28
+
29
+ context 'attributes' do
30
+ it 'allows to read the attributes by name' do
31
+ expect(instance.name).to eq 'John'
32
+ expect(instance.last_name).to eq 'Doe'
33
+ expect(instance.address).to be_a Invariable
34
+ end
35
+
36
+ it 'provides information about its attributes' do
37
+ expect(instance.members).to eq %i[name last_name address]
38
+ end
39
+
40
+ it 'can be checked whether an attribute is defined' do
41
+ expect(instance.member?(:last_name)).to be true
42
+ expect(instance.member?(:city)).to be false
43
+ end
44
+ end
45
+
46
+ context 'Hash-like behavior' do
47
+ it 'provides Hash-like attribute access' do
48
+ expect(instance[:name]).to eq 'John'
49
+ expect(instance[:last_name]).to eq 'Doe'
50
+ expect(instance[:address][:city]).to eq 'Anytown'
51
+ end
52
+
53
+ context 'when the attribute name is unknown' do
54
+ it 'raises a NameError' do
55
+ expect { instance[:size_of_shoe] }.to raise_error(
56
+ NameError,
57
+ 'not member - size_of_shoe'
58
+ )
59
+ end
60
+ end
61
+
62
+ it 'can be converted into a Hash' do
63
+ expect(instance.to_h).to eq(
64
+ name: 'John',
65
+ last_name: 'Doe',
66
+ address: {
67
+ zip: '45678',
68
+ city: 'Anytown',
69
+ street: '123 Main St'
70
+ }
71
+ )
72
+ end
73
+
74
+ it 'can be converted into a customized Hash' do
75
+ converted = instance.to_h { |key, value| ["my_#{key}", value] }
76
+ expect(converted.keys).to eq %w[my_name my_last_name my_address]
77
+ end
78
+
79
+ it 'allows to iterate all attribute name/value pairs' do
80
+ expect { |b| instance.each_pair(&b) }.to yield_successive_args(
81
+ [:name, 'John'],
82
+ [:last_name, 'Doe'],
83
+ [:address, instance.address]
84
+ )
85
+ end
86
+
87
+ it 'provides an Enumerable for its attributes name/value pairs' do
88
+ expect(instance.each_pair).to be_a(Enumerable)
89
+ end
90
+
91
+ it 'can be converted to a compact Hash' do
92
+ john = sample_class.new(name: 'John')
93
+ expect(john.to_h(compact: true)).to eq(name: 'John')
94
+ end
95
+ end
96
+
97
+ context 'Array-like behavior' do
98
+ it 'provides its attribute count' do
99
+ expect(instance.size).to be 3
100
+ end
101
+
102
+ it 'provides Array-like attribute access' do
103
+ expect(instance[0]).to eq 'John'
104
+ expect(instance[1]).to eq 'Doe'
105
+ expect(instance[2]).to be instance.address
106
+ expect(instance[-1]).to be instance.address
107
+ end
108
+
109
+ context 'when the access index is out of bounds' do
110
+ it 'raises a NameError' do
111
+ expect { instance[3] }.to raise_error(IndexError, 'invalid offset - 3')
112
+ end
113
+ end
114
+
115
+ it 'can be converted into an Array' do
116
+ expect(instance.to_a).to eq ['John', 'Doe', instance.address]
117
+ end
118
+
119
+ it 'allows to iterate all attribute values' do
120
+ expect { |b| instance.each(&b) }.to yield_successive_args(
121
+ 'John',
122
+ 'Doe',
123
+ instance.address
124
+ )
125
+ end
126
+
127
+ it 'provides an Enumerable for its attribute values' do
128
+ expect(instance.each).to be_a(Enumerable)
129
+ end
130
+ end
131
+
132
+ context 'comparing' do
133
+ it 'can be compared to other objects' do
134
+ other =
135
+ sample_class.new(
136
+ name: 'John',
137
+ last_name: 'Doe',
138
+ address: {
139
+ zip: '45678',
140
+ city: 'Anytown',
141
+ street: '123 Main St'
142
+ }
143
+ )
144
+ expect(instance == other).to be true
145
+
146
+ other =
147
+ sample_class.new(
148
+ name: 'John',
149
+ last_name: 'Doe',
150
+ address: {
151
+ zip: '45678',
152
+ city: 'Anytown',
153
+ street: '124 Main St' # difffers
154
+ }
155
+ )
156
+ expect(instance == other).to be false
157
+
158
+ other =
159
+ double(
160
+ :other,
161
+ name: 'John',
162
+ last_name: 'Doe',
163
+ address:
164
+ double(
165
+ :other_addr,
166
+ zip: '45678',
167
+ city: 'Anytown',
168
+ street: '123 Main St'
169
+ )
170
+ )
171
+ expect(instance == other).to be true
172
+ end
173
+
174
+ it 'can be tested for equality' do
175
+ other =
176
+ sample_class.new(
177
+ name: 'John',
178
+ last_name: 'Doe',
179
+ address: {
180
+ zip: '45678',
181
+ city: 'Anytown',
182
+ street: '123 Main St'
183
+ }
184
+ )
185
+ expect(instance.eql?(other)).to be true
186
+
187
+ other =
188
+ sample_class.new(
189
+ name: 'John',
190
+ last_name: 'Doe',
191
+ address: {
192
+ zip: '45679', # differs
193
+ city: 'Anytown',
194
+ street: '123 Main St'
195
+ }
196
+ )
197
+ expect(instance.eql?(other)).to be false
198
+
199
+ other = # class differs
200
+ double(
201
+ :other,
202
+ name: 'John',
203
+ last_name: 'Doe',
204
+ address: {
205
+ zip: '45678',
206
+ city: 'Anytown',
207
+ street: '123 Main St'
208
+ }
209
+ )
210
+ expect(instance.eql?(other)).to be false
211
+ end
212
+ end
213
+
214
+ context '#dig pattern' do
215
+ let(:data) { { person: instance } }
216
+
217
+ it 'can be used with attribute names' do
218
+ expect(data.dig(:person, :last_name)).to eq 'Doe'
219
+ expect(data.dig(:person, :zip)).to be_nil
220
+ expect(data.dig(:person, :address, :city)).to eq 'Anytown'
221
+ end
222
+
223
+ it 'can be used with indices' do
224
+ expect(data.dig(:person, 1)).to eq 'Doe'
225
+ expect(data.dig(:person, -1, :zip)).to eq '45678'
226
+ end
227
+ end
228
+
229
+ context 'pattern matching' do
230
+ it 'can be used for named pattern matching' do
231
+ result =
232
+ case instance
233
+ in name: 'Fred', last_name: 'Doe'
234
+ :fred
235
+ in name: 'John', last_name: 'New'
236
+ :not_john
237
+ in name: 'John', last_name: 'Doe', address: { city: 'NY' }
238
+ :john_from_ny
239
+ in name: 'John', last_name: 'Doe', address: { city: 'Anytown' }
240
+ :john
241
+ else
242
+ nil
243
+ end
244
+
245
+ expect(result).to be :john
246
+ end
247
+
248
+ it 'can be used for indexed pattern matching' do
249
+ result =
250
+ case instance
251
+ in 'Fred', 'Doe', *_
252
+ :fred
253
+ in 'John', 'New', *_
254
+ :not_john
255
+ in 'John', 'Doe', *_
256
+ :john
257
+ else
258
+ nil
259
+ end
260
+
261
+ expect(result).to be :john
262
+ end
263
+ end
264
+
265
+ it 'allows to create an updated version of itself' do
266
+ result =
267
+ instance.update(
268
+ name: 'Fred',
269
+ address: {
270
+ zip: '45678',
271
+ city: 'Anytown',
272
+ street: '124 Main St'
273
+ }
274
+ )
275
+ expect(result).to be_a sample_class
276
+ expect(result.name).to eq 'Fred'
277
+ expect(result.last_name).to eq 'Doe'
278
+ expect(result.address.to_h).to eq(
279
+ zip: '45678',
280
+ city: 'Anytown',
281
+ street: '124 Main St'
282
+ )
283
+ end
284
+
285
+ it 'can be inspected' do
286
+ expect(instance.inspect).to include(' name: "John", last_name: "Doe"')
287
+ expect(instance.inspect).to include(
288
+ 'city: "Anytown", zip: "45678", street: "123 Main St"'
289
+ )
290
+ end
291
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: invariable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mike Blumtritt
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-12-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: |
70
+ An Invariable bundles a number of read-only attributes.
71
+ It can be used like a Hash as well as an Array.
72
+ It supports subclassing and pattern matching.
73
+ email:
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files:
77
+ - README.md
78
+ - LICENSE
79
+ files:
80
+ - ".gitignore"
81
+ - ".yardopts"
82
+ - LICENSE
83
+ - README.md
84
+ - gems.rb
85
+ - invariable.gemspec
86
+ - lib/invariable.rb
87
+ - lib/invariable/version.rb
88
+ - rakefile.rb
89
+ - samples/http_options.rb
90
+ - samples/person.rb
91
+ - spec/helper.rb
92
+ - spec/invariable_include_spec.rb
93
+ - spec/invariable_new_spec.rb
94
+ - spec/invariable_spec.rb
95
+ homepage: https://github.com/mblumtritt/invariable
96
+ licenses:
97
+ - BSD-3-Clause
98
+ metadata:
99
+ source_code_uri: https://github.com/mblumtritt/invariable
100
+ bug_tracker_uri: https://github.com/mblumtritt/invariable/issues
101
+ documentation_uri: https://rubydoc.info/github/mblumtritt/invariable
102
+ post_install_message:
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 2.7.0
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.3.3
118
+ signing_key:
119
+ specification_version: 4
120
+ summary: The Invariable data class for Ruby.
121
+ test_files:
122
+ - spec/helper.rb
123
+ - spec/invariable_include_spec.rb
124
+ - spec/invariable_new_spec.rb
125
+ - spec/invariable_spec.rb