invariable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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