attrocity 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +30 -10
  3. data/.rspec +2 -0
  4. data/.travis.yml +4 -0
  5. data/Gemfile.lock +37 -0
  6. data/LICENSE +22 -0
  7. data/README.md +32 -2
  8. data/Rakefile +14 -0
  9. data/TODO.md +36 -0
  10. data/attrocity.gemspec +5 -2
  11. data/lib/attrocity.rb +91 -2
  12. data/lib/attrocity/attributes/attribute.rb +10 -0
  13. data/lib/attrocity/attributes/attribute_methods_builder.rb +47 -0
  14. data/lib/attrocity/attributes/attribute_set.rb +24 -0
  15. data/lib/attrocity/attributes/attribute_template.rb +28 -0
  16. data/lib/attrocity/attributes/attribute_template_set.rb +16 -0
  17. data/lib/attrocity/attributes/attributes_hash.rb +9 -0
  18. data/lib/attrocity/attributes/model_attribute.rb +14 -0
  19. data/lib/attrocity/attributes/model_attribute_set.rb +6 -0
  20. data/lib/attrocity/builders/model_builder.rb +60 -0
  21. data/lib/attrocity/builders/object_extension_builder.rb +18 -0
  22. data/lib/attrocity/coercer_registry.rb +38 -0
  23. data/lib/attrocity/coercers/boolean.rb +12 -0
  24. data/lib/attrocity/coercers/integer.rb +13 -0
  25. data/lib/attrocity/coercers/string.rb +14 -0
  26. data/lib/attrocity/mappers/key_mapper.rb +14 -0
  27. data/lib/attrocity/model.rb +10 -0
  28. data/lib/attrocity/value_extractor.rb +23 -0
  29. data/lib/attrocity/version.rb +1 -1
  30. data/notes.md +253 -0
  31. data/spec/attrocity/attributes/attribute_methods_builder_spec.rb +57 -0
  32. data/spec/attrocity/attributes/attribute_set_spec.rb +24 -0
  33. data/spec/attrocity/attributes/attribute_spec.rb +4 -0
  34. data/spec/attrocity/attributes/attribute_template_set_spec.rb +6 -0
  35. data/spec/attrocity/attributes/attribute_template_spec.rb +25 -0
  36. data/spec/attrocity/attributes/model_attribute_spec.rb +26 -0
  37. data/spec/attrocity/attrocity_spec.rb +83 -0
  38. data/spec/attrocity/coercer_registry_spec.rb +14 -0
  39. data/spec/attrocity/coercers/boolean_spec.rb +32 -0
  40. data/spec/attrocity/coercers/coercer_with_args_spec.rb +9 -0
  41. data/spec/attrocity/coercers/integer_spec.rb +25 -0
  42. data/spec/attrocity/coercers/string_spec.rb +13 -0
  43. data/spec/attrocity/default_value_spec.rb +22 -0
  44. data/spec/attrocity/mappers/key_mapper_spec.rb +22 -0
  45. data/spec/attrocity/mapping_spec.rb +47 -0
  46. data/spec/attrocity/model_spec.rb +34 -0
  47. data/spec/attrocity/object_extension_spec.rb +53 -0
  48. data/spec/attrocity/value_extractor_spec.rb +23 -0
  49. data/spec/spec_helper.rb +93 -0
  50. data/spec/support/examples.rb +79 -0
  51. metadata +99 -8
@@ -0,0 +1,16 @@
1
+ require_relative 'attribute_set'
2
+ require_relative 'attributes_hash'
3
+
4
+ module Attrocity
5
+ class AttributeTemplateSet < AttributeSet
6
+
7
+ def to_attribute_set(data)
8
+ AttributeSet.new.tap do |set|
9
+ self.attributes.each do |attr_template|
10
+ set << attr_template.to_attribute(data)
11
+ end
12
+ end
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ require 'hashie'
2
+
3
+ module Attrocity
4
+ class AttributesHash < Hash
5
+ include Hashie::Extensions::MergeInitializer
6
+ include Hashie::Extensions::IndifferentAccess
7
+ end
8
+ end
9
+
@@ -0,0 +1,14 @@
1
+ module Attrocity
2
+ class ModelAttribute
3
+ attr_reader :name, :model_class
4
+
5
+ def initialize(name, model_class)
6
+ @name = name
7
+ @model_class = model_class
8
+ end
9
+
10
+ def model(data)
11
+ model_class.new(AttributesHash.new(data)).model
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,6 @@
1
+ require 'attrocity/attributes/attribute_set'
2
+
3
+ module Attrocity
4
+ class ModelAttributeSet < AttributeSet
5
+ end
6
+ end
@@ -0,0 +1,60 @@
1
+ module Attrocity
2
+ class ModelBuilder < Module
3
+
4
+ def included(klass)
5
+ klass.extend(Attrocity::ModuleMethods)
6
+ klass.class_eval do
7
+ attr_reader :raw_data, :attribute_set
8
+ end
9
+ klass.send(:include, Initializer)
10
+ klass.send(:include, InstanceMethods)
11
+ end
12
+
13
+ module Initializer
14
+ def initialize(data={})
15
+ @raw_data = AttributesHash.new(data)
16
+ @attribute_set = init_value_attributes
17
+ build_attributes_methods
18
+ setup_model_attributes
19
+ end
20
+ end
21
+
22
+ module InstanceMethods
23
+ def model
24
+ Model.new(attribute_set.to_h.merge(model_attributes_hash))
25
+ end
26
+
27
+ private
28
+
29
+ def init_value_attributes
30
+ self.class.attribute_set.to_attribute_set(raw_data)
31
+ end
32
+
33
+ def build_attributes_methods
34
+ AttributeMethodsBuilder.for_attribute_set(self, attribute_set).build
35
+ end
36
+
37
+ def setup_model_attributes
38
+ model_attributes.each do |model_attr|
39
+ name = model_attr.name
40
+ define_singleton_method(name) {
41
+ instance_eval("@#{name} ||= model_attr.model(raw_data)")
42
+ }
43
+ end
44
+ end
45
+
46
+ def model_attributes
47
+ @model_attributes ||= self.class.model_attribute_set.attributes
48
+ end
49
+
50
+ def model_attributes_hash
51
+ Hash.new.tap do |h|
52
+ model_attributes.each do |model_attr|
53
+ name = model_attr.name
54
+ h[name] = self.send(name)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,18 @@
1
+ module Attrocity
2
+ class ObjectExtensionBuilder < Module
3
+
4
+ def included(mod)
5
+ mod.extend(Attrocity::ModuleMethods)
6
+ mod.extend(ModuleHooks)
7
+ end
8
+
9
+ module ModuleHooks
10
+ def extend_object(obj)
11
+ value_attr_set = self.attribute_set.to_attribute_set(obj.raw_data)
12
+ obj.attribute_set << value_attr_set.attributes
13
+ AttributeMethodsBuilder.for_attribute_set(obj, value_attr_set).build
14
+ end
15
+ end
16
+
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ module Attrocity
2
+
3
+ UnknownCoercerError = Class.new(StandardError)
4
+
5
+ class CoercerRegistry
6
+ def self.register(&block)
7
+ class_eval(&block) if block_given?
8
+ end
9
+
10
+ def self.add(name, coercer_class)
11
+ registry[name] = coercer_class
12
+ end
13
+
14
+ def self.to_s
15
+ registry.inspect
16
+ end
17
+
18
+ def self.coercer_for(name)
19
+ registry.fetch(name)
20
+ rescue KeyError
21
+ raise UnknownCoercerError
22
+ end
23
+
24
+ def self.instance_for(name, params={})
25
+ if params.empty?
26
+ coercer_for(name).new
27
+ else
28
+ coercer_for(name).new(params)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def self.registry
35
+ @registry ||= {}
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ module Attrocity
2
+ module Coercers
3
+ class Boolean
4
+
5
+ def coerce(value)
6
+ true & value
7
+ end
8
+
9
+ end
10
+ end
11
+ end
12
+
@@ -0,0 +1,13 @@
1
+ module Attrocity
2
+ module Coercers
3
+ class Integer
4
+ def coerce(value)
5
+ if value.nil? || value.to_s.empty?
6
+ nil
7
+ else
8
+ Integer(value)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module Attrocity
2
+ module Coercers
3
+ class String
4
+ def coerce(value)
5
+ if value.nil?
6
+ nil
7
+ else
8
+ String(value)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+
@@ -0,0 +1,14 @@
1
+ module Attrocity
2
+ class KeyMapper
3
+ attr_reader :key, :default_value
4
+
5
+ def initialize(key, default_value=nil)
6
+ @key = key
7
+ @default_value = default_value
8
+ end
9
+
10
+ def call(_, attributes_data)
11
+ attributes_data.fetch(key, default_value)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ module Attrocity
2
+ class Model
3
+ def initialize(attributes)
4
+ attributes.each do |k,v|
5
+ self.define_singleton_method(k) { v }
6
+ self.define_singleton_method("#{k}?") { !!v }
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,23 @@
1
+ module Attrocity
2
+ class ValueExtractor
3
+ attr_reader :data, :mapper, :coercer
4
+
5
+ def initialize(data, mapper:, coercer:)
6
+ @data, @mapper, @coercer = data, mapper, coercer
7
+ end
8
+
9
+ def value
10
+ coerce(map)
11
+ end
12
+
13
+ private
14
+
15
+ def map
16
+ mapper.call(nil, data)
17
+ end
18
+
19
+ def coerce(value)
20
+ coercer.coerce(value)
21
+ end
22
+ end
23
+ end
@@ -1,3 +1,3 @@
1
1
  module Attrocity
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -0,0 +1,253 @@
1
+ # Notes
2
+
3
+ ## Mappers/Coercers data injection
4
+
5
+ The crux of the issue is that for mapping or coercing I might want to inject some values, such as null values to compare against. These values differ on a per-attribute basis.
6
+
7
+ The library calls specific methods with specific arguments. I need to get those args into the library.
8
+
9
+ It does feel like more of a coercion problem than a mapping problem. I think I originally reached for the mapper because I thought I had more control.
10
+
11
+ - [ ] ? Via coercer registry?
12
+
13
+ - [ ] ? Change to the attribute DSL for coercer? Pass hash with args? If not a hash, but simply a symbol, assume it's the name?
14
+
15
+ - [ ] ? How might we do this with blocks?
16
+
17
+ ## Current refactorings
18
+
19
+ - [ ] Process pending specs: delete unnecessary, make valid ones pass.
20
+
21
+ ## Handling missing data
22
+
23
+ I think these are the rules...
24
+
25
+ It should probably be nil. Perhaps if the mapper returns nil we don't even bother coercing it. There's a concept here and it's around extracting data. Every attribute has a coercer, by rule of API, and every attribute has a mapper, either explicitly declared or the default KeyMapper. When an attribute is missing, in other words when the mapper cannot extract data (returns nil), the default value is applied. The "default" default value is nil. When the default value is applied, no coercion is performed.
26
+
27
+ ValueExtractor (or a better named class) takes a mapper, a coercer, and a default value, which defaults to nil.
28
+
29
+ - [ ] Write up some spec/examples and also for the README
30
+
31
+ ## Attributes
32
+
33
+ ### Collaborators
34
+
35
+ In which collaborator would it most make sense to add in default value behavior?
36
+
37
+ Mapper: Simply retrieves the data from raw attributes data. `call` it with an object and a hash of data and it returns a value. It might return nil. Potentially, it could accept a default and fallback to that value, but I'm not sure that's within the responsibility of this object.
38
+
39
+ Coercer: Simply assures the data its passed comes back as the correct type.
40
+
41
+ Who knows how to assemble/initialize a Mapper? I don't think Attribute needs to
42
+ be the keeper of that knowledge. If Mapper's public interface is to receive the
43
+ call message with obj, attributes_data, and an optional default value, then a
44
+ fully realized mapper should be passed into an Attribute, moving that work
45
+ outside of Attribute and simplifying it.
46
+
47
+ Of the 2 objects, coercer and mapper, if I had to choose between the two to shim
48
+ default value behavior, I would choose the mapper.
49
+
50
+ Do we map a coerced value OR do we coerce a mapped value? I think we coerce a
51
+ mapped value. Right. Map, then send the result to the coercer.
52
+
53
+ Do we map/coerce before handing the value to an InstanceAttribute?
54
+
55
+ I like the idea of working inside-out here, from the innermost objects outward,
56
+ because I think we have the right objects, they're just combined in funky ways.
57
+ At this point, it feels right to start with mapper -> coercer -> ?.
58
+ (ClassAttribute probably doesn't need any of those data-related collaborators.)
59
+
60
+ ### Coercers
61
+
62
+ I'm concerned the return value/behavior of coercers is inconsistent. I'm not
63
+ sure what to do about it yet, though, other than note it.
64
+
65
+ Coercers::Integer - It's possible in a couple different ways to raise an error,
66
+ so we normalize the different kinds of errors into a single
67
+ Attrocity::CoercionError. We are simply using Kernel.Integer for this coercer,
68
+ which is the source for raising (at least) a couple different errors: TypeError,
69
+ ArgumentError.
70
+
71
+ Coercers::String: - It doesn't seem possible for this coercer to raise an error
72
+ in its current implementation. It uses Kernel.String, which in turn uses to_s,
73
+ which is implemented in Object.
74
+
75
+ Coercers::Boolean - At this point, this is a custom implementation, specific to
76
+ RentPath, but I'm thinking that it should default to the Ruby notion of
77
+ truthiness. Yes, the default boolean coercer will adhere to the Ruby notion of
78
+ truthiness. But we need to then inject a coercer that's not part of the built-in
79
+ coercers, which we should probably do in RentalsModels. This work is done.
80
+
81
+ ### ClassAttribute
82
+
83
+ - [ ] Start with default_value as a simple Ruby attribute (data).
84
+
85
+ ### InstanceAttribute
86
+
87
+ Start here and work backwards towards ClassAttribute.
88
+
89
+ ---
90
+
91
+ DefaultValue
92
+ NullDefaultValue
93
+
94
+ Should a default value of nil be supported?
95
+
96
+ default_value_for
97
+
98
+ ---
99
+
100
+ clone is confusing
101
+
102
+ ---
103
+
104
+ Is options a class or a module?
105
+
106
+ Options attributes are first-class attributes (with coercion, mapping, etc.)
107
+
108
+ # TODO
109
+
110
+ - Convert all specs to require spec_helper
111
+
112
+ - Attribute should have a method for setting its own value that take the
113
+ arguments that attribute set uses to do the job. ???
114
+
115
+ - [ ] ? Should attrocity_spec specs be moved to something like module builder
116
+ spec?
117
+
118
+ - [ ] ? Re-evaluate attrocity_spec. There are some oddities. It seems like it's
119
+ been a default place to put spec code that we weren't sure about the eventual
120
+ home for.
121
+
122
+ Attribute set cloning
123
+ ---------------------
124
+
125
+ Consider making attributes that are attached to a class, that basically act as
126
+ templates for instance attributes, to be a different kind of thing so that we're
127
+ not cloning, per se, but creating an instance variant of the class attribute
128
+ variant on the fly. So, factory approach vs. cloning approach.
129
+
130
+ Inventory
131
+ ---------
132
+
133
+ ### Default values
134
+
135
+ For now, let's just implement the simple version of default values. Will need to
136
+ think through best implementation. Guessing some here... the simplest
137
+ implementation is to pass an option to Attribute.new and have the attribute
138
+ handle default values. DO NOT put this responsibility into the coercer. Coercers
139
+ must be kept simple.
140
+
141
+ How to handle defaults because `Integer(nil)` raises an error
142
+
143
+ Probably handle defaults at attribute level. Based on default property of an
144
+ attribute instance, it will handle coercion errors accordingly. Does this affect
145
+ validation?
146
+
147
+ Default values should not be conflated with coercers. However, when analyzing a
148
+ default value, we might want to check that it does not get transformed when
149
+ coerced. In other words, with an integer coercer and a default value of 1,
150
+ Integer(1) does not change the value, but a default value of '1' would be
151
+ considered incorrect and we could catch that by coercing and comparing.
152
+
153
+ It should be possible to have a nil value, but I don't like the idea of nil
154
+ being the fallback value. A string coerced attribute should probably default to
155
+ ''. Not sure what an integer coerced attribute should default to. Maybe nil as
156
+ fallback is the only consistent way to do this.
157
+
158
+ ### Validation?
159
+
160
+
161
+ Design inspiration
162
+ ------------------
163
+
164
+ - Virtus
165
+ - attrio
166
+ http://igor-alexandrov.github.io/blog/2013/05/23/attrio-typed-attributes-for-ruby-objects/
167
+
168
+
169
+ Concepts
170
+ --------
171
+
172
+ - Coercing: Converting attribute data to the desired type
173
+
174
+ - Mapping: Getting attribute data from a known hash key that differs from the
175
+ attribute name
176
+
177
+
178
+ Ideas
179
+ -----
180
+
181
+ Lazy forwarding to attribute_set (using method_missing or similar) will allow us
182
+ to modify the attribute set on an object instance without having to muck around
183
+ with undefining methods and such. For an attribute named :age, we want to allow
184
+ for methods: age, age=, [:age], age?. We want to be able to add and remove
185
+ attributes and the object should just work without a bunch of metaprogramming on
186
+ our part.
187
+
188
+ On initialize, attributes are collected into an instance-scope AttributeSet.
189
+ When an instance of this class is extended, e.g., `my_class.extend(MyModule)`,
190
+ the attribute set of the instance gets the attributes of the module. This is
191
+ very much like Virtus, only we're working with instances. We might also work
192
+ with classes, but definitely instances, which allows us to do DCI-style dynamic
193
+ role/behavior extensions.
194
+
195
+
196
+ Documentation
197
+ -------------
198
+
199
+ Initialization with a hash is required.
200
+
201
+ ### AttributeSet
202
+
203
+ - AttributeSet#to_h
204
+ - AttributeSet#to_value_object. Should attribute_set be the thing that handles
205
+ handing back a value object.
206
+
207
+ Value object may be the wrong term. It may be 'immutable' not value. Investigate
208
+ ways to generate bare immutable objects in Ruby.
209
+ [immutable_struct](https://github.com/iconara/immutable_struct) looks like a
210
+ good and not bloated option. It's a bit dated, so might need to be forked.
211
+
212
+ Lightweight, but not great option:
213
+
214
+ ```ruby
215
+ # from attributes hash
216
+ Struct.new(*keys).new(*values).freeze
217
+ # raises a RuntimeError on mutation
218
+ ```
219
+
220
+ Attrocity objects forward #umapped_attributes and #attributes to AttributeSet
221
+
222
+ ### Hooks
223
+
224
+ Implement Mod.extended(obj) hook such that when the obj.extend(Mod) triggers it,
225
+ the obj.attribute_set is merged with Mod.attributes (or Mod.attribute_set)
226
+
227
+ Coercers
228
+ --------
229
+
230
+ Comes with a built-in set of coercers.
231
+
232
+ Additional coercers are added through a registry.
233
+
234
+ The library emphasizes custom coercion over types. For example,
235
+
236
+ ```ruby
237
+ attribute :publication, :book # looks for a Book coercer
238
+ ```
239
+
240
+ Should coercers be initialized with an attribute? That way they can get
241
+ defaults, etc.
242
+
243
+ I don't think the cost of inheritance is worth the enforcement of implementing a
244
+ simple API. Let's just define what coercers are and document it.
245
+
246
+ The coercion API is defined as: an instance method called coerce. Why an
247
+ instance method? Because it's more accommodating to future changes.
248
+
249
+ Documentation for README
250
+ ------------------------
251
+
252
+ Attribute values are set and retrieved through their containing AttributeSet.
253
+ The Attribute set acts as an aggregate root (in DDD parlance).