easy_attrs 0.0.1

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/easy_attrs.rb +219 -0
  3. metadata +72 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 58184a091c40cb53d3095fe0fb5dcafb569ba8f6
4
+ data.tar.gz: c148a6e5a1a6c4ca143286bcde5a299de9c463df
5
+ SHA512:
6
+ metadata.gz: a8861b44fbd803d3d651f4fc27df92a664482956bce99e68a44db14daad59c7a75ef96e6b60d3a12b0e22650c753bc2bb09b60289ddbd522a30e79ffd9fc96d6
7
+ data.tar.gz: d2331453c8e4c130a2aefb085f8d1cbb446a900c6ab23abd5f33d1a605380d0301b85233ccff4a0a5fca19ff30d66103a197a0dc2137489ac67f53d720fcf403
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/hash/keys'
5
+ require 'active_support/core_ext/string/inflections'
6
+ #
7
+ # This module is meant to be used for objects being initialized with either raw
8
+ # JSON or a regular Hash as opposed to a database row (like sub classes of
9
+ # ActiveRecord). The raw_input (JSON or Hash) can come from any source but it
10
+ # would typically be a response from an external API. You would have to build
11
+ # your own client for the objects you're interested in (see the readme for an
12
+ # example of a design).
13
+ #
14
+ # EasyAttrs only keeps attributes specified in the `instance_variables_only`,
15
+ # `accessors`, `writers` and `readers` class macros and discards all other keys
16
+ # in the raw_input.
17
+ #
18
+ # `accessors`, `writers` and `readers` are self explanatory and any
19
+ # symbol/string passed to it will be made an attr_accessor, attr_writer or
20
+ # attr_reader.
21
+ #
22
+ # `instance_variables_only` is here in case the including class needs access to
23
+ # a specific key in the raw_input but does not want to make this a public
24
+ # method (accessor/reader/writer). It creates an instance variable for the
25
+ # symbol/string passed in whose value is the value under that key in the
26
+ # raw_input. See the `Item` class for an example of how it is used.
27
+ #
28
+ # Usage:
29
+ #
30
+ # Specify the `instance_variables_only`, `accessors`, `writers` and `readers`
31
+ # the including class will use and EasyAttrs will create the methods or
32
+ # variables for it. Any of those class macros can be left out.
33
+ #
34
+ # class Competitor
35
+ # include EasyAttrs
36
+
37
+ # readers :id, :name
38
+ # end
39
+ #
40
+ # This will create the `id` and `name` readers on the class based on the
41
+ # raw_input and every other key in the input will be unused.
42
+ #
43
+ # $ json = {id: 1, name: 'amazon', other_key: 'other_value'}.to_json
44
+ # $ comp = Competitor.new(json)
45
+ # => #<Competitor:0x007faef0f2f930 @id=1, @name="amazon">
46
+ #
47
+ # Here is a slightly more complex example with `@instance_variables_only`:
48
+ #
49
+ # class Item
50
+ # include EasyAttrs
51
+
52
+ # instance_variables_only :nested_data
53
+ # accessors :name
54
+ # readers :id, :category
55
+
56
+ # def elements
57
+ # @nested_data['elements'].map(&:upcase)
58
+ # end
59
+ # end
60
+ #
61
+ # Note how `instance_variables_only` is used for `:nested_data`. We want to use
62
+ # the data contained under the nested_data key in the raw_input but we want
63
+ # to transform the data before exposing it to the outside world. The
64
+ # transformation is done in the `elements` public method.
65
+ #
66
+ # Another thing to note is that if a class defines some attributes (readers for
67
+ # example) then all subclasses of that class will automatically have the those
68
+ # readers created and the values set on initialize (see below for an example).
69
+ #
70
+ # class Item
71
+ # include EasyAttrs
72
+
73
+ # readers :id, :name
74
+ # end
75
+ #
76
+ # class BetterItem < Item
77
+ # accessors :price
78
+ # end
79
+ #
80
+ # $ BetterItem.new(id: 1, name: 'Better Item', price: 25)
81
+ # => #<BetterItem:0x007faef0f2f930 @id=1, @name="Better Item", @price=25>
82
+ #
83
+ # class EvenBetterItem < BetterItem
84
+ # readers :category
85
+ # end
86
+ #
87
+ # $ EvenBetterItem.new(id: 1, name: 'Better Item', price: 25, category: 'BOOOH')
88
+ # => #<EvenBetterItem:0x007fbb10371978
89
+ # @category="BOOOH",
90
+ # @id=1,
91
+ # @name="Better Item",
92
+ # @price=25
93
+ # >
94
+ #
95
+
96
+ module EasyAttrs
97
+ module ClassMethods
98
+
99
+ # `all_attributes` needs to be public for instances to call it in
100
+ # `intialize`.
101
+ #
102
+ def all_attributes
103
+ @_all_attributes ||= begin
104
+ attributes = []
105
+
106
+ # Yes, this is a nested loop.
107
+ # The result is memoized so it only happens when the first instance of
108
+ # the including class is initialized and the length of the ancestor
109
+ # chain is rarely going to be very long (it will of course vary
110
+ # depending on the class hierarchy of the applicaton using EasyAttrs).
111
+ #
112
+ easy_attrs_ancestors.each do |a|
113
+ [:readers, :writers, :accessors, :instance_variables_only].each do |i_var|
114
+ i_var_from_ancestor = a.instance_variable_get("@#{i_var}")
115
+
116
+ if i_var_from_ancestor
117
+ attributes.concat(i_var_from_ancestor)
118
+ end
119
+ end
120
+ end
121
+
122
+ attributes
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def readers *attrs
129
+ unless attrs.empty?
130
+ @readers = attrs
131
+
132
+ class_eval do
133
+ attr_reader *attrs
134
+ end
135
+ end
136
+ end
137
+
138
+ def writers *attrs
139
+ unless attrs.empty?
140
+ @writers = attrs
141
+
142
+ class_eval do
143
+ attr_writer *attrs
144
+ end
145
+ end
146
+ end
147
+
148
+ def accessors *attrs
149
+ unless attrs.empty?
150
+ @accessors = attrs
151
+
152
+ class_eval do
153
+ attr_accessor *attrs
154
+ end
155
+ end
156
+ end
157
+
158
+ def instance_variables_only *attrs
159
+ @instance_variables_only = attrs unless attrs.empty?
160
+ end
161
+
162
+ # The ancestor chain includes `self`, which is exactly what we want in this
163
+ # case because we want to grab all the class instance variables of all the
164
+ # ancestors AND tose of the current class so we can find all attributes to
165
+ # use when an instance calls `new`.
166
+ #
167
+ def easy_attrs_ancestors
168
+ ancestors.select { |a| a.include? EasyAttrs }
169
+ end
170
+ end
171
+
172
+ def self.included klass
173
+ klass.extend ClassMethods
174
+ end
175
+
176
+ # Transform all top level keys to snake case symbols to handle camel case
177
+ # input.
178
+ # Then, if a given key is part of `all_attributes` AND its content is a Hash,
179
+ # recursively transform all keys to snake case symbols.
180
+ # We want to avoid running `deep_transform_keys` on the raw_input because we
181
+ # may end up transforming a lot of deeply nested keys that will be discarded
182
+ # if they are not part of `all_attributes`.
183
+ #
184
+ # It's fastest to pass a Hash as input. A Json string is slower. A Hash with
185
+ # camel case keys is even slower. And a Json string with camel case keys is
186
+ # the slowest.
187
+ #
188
+ def initialize raw_input={}
189
+ input = parse_input(raw_input)
190
+ set_instance_variables(input) unless input.empty?
191
+ end
192
+
193
+ private
194
+
195
+ def parse_input raw_input
196
+ if raw_input.is_a?(Hash)
197
+ raw_input
198
+ elsif raw_input.is_a?(String)
199
+ ActiveSupport::JSON.decode(raw_input)
200
+ else
201
+ {}
202
+ end.map { |k, v| [k.to_s.underscore.to_sym, v] }.to_h
203
+ end
204
+
205
+ def set_instance_variables attrs_as_hash
206
+ self.class.all_attributes.each do |attribute|
207
+ raw_value = attrs_as_hash[attribute.to_sym]
208
+ next if raw_value.nil?
209
+
210
+ value = if raw_value.is_a?(Hash)
211
+ raw_value.deep_transform_keys { |k| k.to_s.underscore.to_sym }
212
+ else
213
+ raw_value
214
+ end
215
+
216
+ instance_variable_set("@#{attribute}", value)
217
+ end
218
+ end
219
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easy_attrs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Noe Stauffert
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-11-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.7'
41
+ description:
42
+ email: noe.stauffert@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/easy_attrs.rb
48
+ homepage: http://www.noestauffert.com
49
+ licenses:
50
+ - MIT
51
+ metadata: {}
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubyforge_project:
68
+ rubygems_version: 2.6.12
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: easy_attrs allows you to build objects easily
72
+ test_files: []