attributor 2.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +4 -0
  5. data/CHANGELOG.md +52 -0
  6. data/Gemfile +3 -0
  7. data/Guardfile +12 -0
  8. data/LICENSE +22 -0
  9. data/README.md +62 -0
  10. data/Rakefile +28 -0
  11. data/attributor.gemspec +40 -0
  12. data/lib/attributor.rb +89 -0
  13. data/lib/attributor/attribute.rb +271 -0
  14. data/lib/attributor/attribute_resolver.rb +116 -0
  15. data/lib/attributor/dsl_compiler.rb +106 -0
  16. data/lib/attributor/exceptions.rb +38 -0
  17. data/lib/attributor/extensions/randexp.rb +10 -0
  18. data/lib/attributor/type.rb +117 -0
  19. data/lib/attributor/types/boolean.rb +26 -0
  20. data/lib/attributor/types/collection.rb +135 -0
  21. data/lib/attributor/types/container.rb +42 -0
  22. data/lib/attributor/types/csv.rb +10 -0
  23. data/lib/attributor/types/date_time.rb +36 -0
  24. data/lib/attributor/types/file_upload.rb +11 -0
  25. data/lib/attributor/types/float.rb +27 -0
  26. data/lib/attributor/types/hash.rb +337 -0
  27. data/lib/attributor/types/ids.rb +26 -0
  28. data/lib/attributor/types/integer.rb +63 -0
  29. data/lib/attributor/types/model.rb +316 -0
  30. data/lib/attributor/types/object.rb +19 -0
  31. data/lib/attributor/types/string.rb +25 -0
  32. data/lib/attributor/types/struct.rb +50 -0
  33. data/lib/attributor/types/tempfile.rb +36 -0
  34. data/lib/attributor/version.rb +3 -0
  35. data/spec/attribute_resolver_spec.rb +227 -0
  36. data/spec/attribute_spec.rb +597 -0
  37. data/spec/attributor_spec.rb +25 -0
  38. data/spec/dsl_compiler_spec.rb +130 -0
  39. data/spec/spec_helper.rb +30 -0
  40. data/spec/support/models.rb +81 -0
  41. data/spec/support/types.rb +21 -0
  42. data/spec/type_spec.rb +134 -0
  43. data/spec/types/boolean_spec.rb +85 -0
  44. data/spec/types/collection_spec.rb +286 -0
  45. data/spec/types/container_spec.rb +49 -0
  46. data/spec/types/csv_spec.rb +17 -0
  47. data/spec/types/date_time_spec.rb +90 -0
  48. data/spec/types/file_upload_spec.rb +6 -0
  49. data/spec/types/float_spec.rb +78 -0
  50. data/spec/types/hash_spec.rb +372 -0
  51. data/spec/types/ids_spec.rb +32 -0
  52. data/spec/types/integer_spec.rb +151 -0
  53. data/spec/types/model_spec.rb +401 -0
  54. data/spec/types/string_spec.rb +55 -0
  55. data/spec/types/struct_spec.rb +189 -0
  56. data/spec/types/tempfile_spec.rb +6 -0
  57. metadata +348 -0
@@ -0,0 +1,26 @@
1
+ module Attributor
2
+
3
+ class Ids < CSV
4
+
5
+
6
+ def self.for(type)
7
+ identity_name = type.options.fetch(:identity) do
8
+ raise AttributorException, "no identity found for #{type.name}"
9
+ end
10
+
11
+ identity_attribute = type.attributes.fetch(identity_name) do
12
+ raise AttributorException, "#{type.name} does not have attribute with name '#{identity_name}'"
13
+ end
14
+
15
+ Class.new(self) do
16
+ @member_attribute = identity_attribute
17
+ @member_type = identity_attribute.type
18
+ end
19
+
20
+ end
21
+
22
+ def self.of(type)
23
+ raise "Invalid definition of Ids type. Defining Ids.of(type) is not allowed, you probably meant to do Ids.for(type) instead"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,63 @@
1
+
2
+
3
+ module Attributor
4
+
5
+ class Integer
6
+ include Type
7
+
8
+ EXAMPLE_RANGE = 1000.freeze
9
+
10
+ def self.native_type
11
+ return ::Integer
12
+ end
13
+
14
+
15
+ def self.example(context=nil, options: {})
16
+ validate_options(options)
17
+
18
+ # Set default values
19
+ if options[:min].nil? && options[:max].nil?
20
+ min = 0
21
+ max = EXAMPLE_RANGE
22
+ elsif options[:min].nil?
23
+ max = options[:max]
24
+ min = max - EXAMPLE_RANGE
25
+ elsif options[:max].nil?
26
+ min = options[:min]
27
+ max = min + EXAMPLE_RANGE
28
+ else
29
+ min = options[:min]
30
+ max = options[:max]
31
+ end
32
+
33
+ # Generate random number on interval [min,max]
34
+ rand(max-min+1) + min
35
+ end
36
+
37
+ def self.load(value, context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
38
+ Integer(value)
39
+ rescue TypeError
40
+ super
41
+ end
42
+
43
+ def self.validate_options(options)
44
+ if options.has_key?(:min) && options.has_key?(:max)
45
+ # Both :max and :min must be integers
46
+ raise AttributorException.new("Invalid range: [#{options[:min].inspect}, #{options[:max].inspect}]") if !options[:min].is_a?(::Integer) || !options[:max].is_a?(::Integer)
47
+
48
+ # :max cannot be less than :min
49
+ raise AttributorException.new("Invalid range: [#{options[:min].inspect}, #{options[:max].inspect}]") if options[:max] < options[:min]
50
+ elsif !options.has_key?(:min) && options.has_key?(:max)
51
+ # :max must be an integer
52
+ raise AttributorException.new("Invalid range: [, #{options[:max].inspect}]") if !options[:max].is_a?(::Integer)
53
+ elsif options.has_key?(:min) && !options.has_key?(:max)
54
+ # :min must be an integer
55
+ raise AttributorException.new("Invalid range: [#{options[:min].inspect},]") if !options[:min].is_a?(::Integer)
56
+ else
57
+ # Neither :min nor :max were given, noop
58
+ end
59
+ true
60
+ end
61
+
62
+ end
63
+ end
@@ -0,0 +1,316 @@
1
+ module Attributor
2
+ class Model
3
+ include Attributor::Type
4
+ MAX_EXAMPLE_DEPTH = 5
5
+ CIRCULAR_REFERENCE_MARKER = '...'.freeze
6
+
7
+ # FIXME: this is not the way to fix this. Really we should add finalize! to Models.
8
+ undef :timeout
9
+ undef :format
10
+
11
+
12
+ def self.inherited(klass)
13
+ klass.instance_eval do
14
+ @saved_blocks = []
15
+ @options = {}
16
+ @attributes = {}
17
+ end
18
+ end
19
+
20
+ class << self
21
+ attr_reader :options
22
+ end
23
+
24
+
25
+ # Define accessors for attribute of given name.
26
+ #
27
+ # @param name [::Symbol] attribute name
28
+ #
29
+ def self.define_accessors(name)
30
+ name = name.to_sym
31
+ self.define_reader(name)
32
+ self.define_writer(name)
33
+ end
34
+
35
+
36
+ def self.define_reader(name)
37
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
38
+ def #{name}
39
+ return @attributes[:#{name}] if @attributes.has_key?(:#{name})
40
+
41
+ @attributes[:#{name}] = begin
42
+ if (proc = @lazy_attributes.delete :#{name})
43
+ if proc.arity > 0
44
+ proc.call(self)
45
+ else
46
+ proc.call
47
+ end
48
+ end
49
+ end
50
+ end
51
+ RUBY
52
+ end
53
+
54
+
55
+ def self.define_writer(name)
56
+ attribute = self.attributes[name]
57
+ context = ["assignment","of(#{name})"].freeze
58
+ # note: paradoxically, using define_method ends up being faster for the writer
59
+ # attribute is captured by the block, saving us from having to retrieve it from
60
+ # the class's attributes hash on each write.
61
+ module_eval do
62
+ define_method(name.to_s + "=") do |value|
63
+ # TODO: what type of context do we report with unscoped assignments?
64
+ # => for now this would report "assignment.of(field_name)" is that good?
65
+ @attributes[name] = attribute.load(value,context)
66
+ end
67
+ end
68
+ end
69
+
70
+
71
+ def self.describe(shallow=false)
72
+ hash = super
73
+
74
+ # Spit attributes if it's the root or if it's an anonymous structures
75
+ if ( !shallow || self.name == nil) && self.attributes
76
+ hash[:attributes] = self.attributes.each_with_object({}) do |(sub_name, sub_attribute), sub_attributes|
77
+ sub_attributes[sub_name] = sub_attribute.describe(true)
78
+ end
79
+ end
80
+
81
+ hash
82
+ end
83
+
84
+
85
+ def self.example(context=nil, **values)
86
+ result = self.new
87
+
88
+ context = case context
89
+ when nil
90
+ ["#{self.name}-#{result.object_id.to_s}"]
91
+ when ::String
92
+ [context]
93
+ else
94
+ context
95
+ end
96
+
97
+ example_depth = context.size
98
+
99
+ self.attributes.each do |sub_attribute_name,sub_attribute|
100
+ if sub_attribute.attributes
101
+ # TODO: add option to raise an exception in this case?
102
+ next if example_depth > MAX_EXAMPLE_DEPTH
103
+ end
104
+
105
+ sub_context = self.generate_subcontext(context,sub_attribute_name)
106
+
107
+ result.lazy_attributes[sub_attribute_name] = Proc.new do
108
+ value = values.fetch(sub_attribute_name) do
109
+ sub_attribute.example(sub_context, parent: result)
110
+ end
111
+
112
+ sub_attribute.load(value,sub_context)
113
+ end
114
+ end
115
+
116
+ result
117
+ end
118
+
119
+ def self.dump(value, **opts)
120
+ value.dump(opts)
121
+ end
122
+
123
+ def self.native_type
124
+ self
125
+ end
126
+
127
+ def self.check_option!(name, value)
128
+ case name
129
+ when :identity
130
+ raise AttributorException, "Invalid identity type #{value.inspect}" unless value.kind_of?(::Symbol)
131
+ :ok # FIXME ... actually do something smart, that doesn't break lazy attribute creation
132
+ when :reference
133
+ :ok # FIXME ... actually do something smart
134
+ when :dsl_compiler
135
+ :ok # FIXME ... actually do something smart
136
+ when :dsl_compiler_options
137
+ :ok
138
+ else
139
+ super
140
+ end
141
+ end
142
+
143
+
144
+ # Model-specific decoding and coercion of the attribute.
145
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
146
+ return value if value.nil?
147
+ return value if value.kind_of?(self.native_type)
148
+
149
+ context = Array(context)
150
+
151
+ hash = case value
152
+ when ::String
153
+ # Strings are assumed to be JSON-serialized for now.
154
+ begin
155
+ JSON.parse(value)
156
+ rescue
157
+ raise DeserializationError, context: context, from: value.class, encoding: "JSON" , value: value
158
+ end
159
+ when ::Hash
160
+ value
161
+ else
162
+ raise IncompatibleTypeError, context: context, value_type: value.class, type: self
163
+ end
164
+
165
+ self.from_hash(hash,context)
166
+ end
167
+
168
+
169
+ def self.from_hash(hash,context)
170
+ model = self.new
171
+
172
+ self.attributes.each do |attribute_name, attribute|
173
+ # OPTIMIZE: deleting the keys as we go is mucho faster, but also very risky
174
+ # Note: use "load" vs. attribute assignment so we can propagate the right context down.
175
+ sub_context = self.generate_subcontext(context,attribute_name)
176
+ model.attributes[attribute_name] = attribute.load(hash[attribute_name] || hash[attribute_name.to_s], sub_context)
177
+ end
178
+
179
+ unknown_keys = hash.keys.collect {|k| k.to_sym} - self.attributes.keys
180
+
181
+ if unknown_keys.any?
182
+ raise AttributorException, "Unknown attributes received: #{unknown_keys.inspect} while loading #{Attributor.humanize_context(context)}"
183
+ end
184
+
185
+ model
186
+ end
187
+
188
+ # method to only define the block of attributes for the model
189
+ # This will be a lazy definition. So we'll only save it in an instance class var for later.
190
+ def self.attributes(opts={},&block)
191
+ if block_given?
192
+ @saved_blocks.push(block)
193
+ @options.merge!(opts)
194
+ elsif @saved_blocks.any?
195
+ self.definition
196
+ end
197
+
198
+ @attributes
199
+ end
200
+
201
+
202
+ def self.validate(object,context=Attributor::DEFAULT_ROOT_CONTEXT,_attribute)
203
+ context = [context] if context.is_a? ::String
204
+
205
+ unless object.kind_of?(self)
206
+ raise ArgumentError, "#{self.name} can not validate object of type #{object.class.name} for #{Attributor.humanize_context(context)}."
207
+ end
208
+
209
+ object.validate(context)
210
+ end
211
+
212
+
213
+ def self.dsl_class
214
+ @options[:dsl_compiler] || DSLCompiler
215
+ end
216
+
217
+ # Returns the "compiled" definition for the model.
218
+ # By "compiled" I mean that it will create a new Compiler object with the saved options and saved block that has been passed in the 'attributes' method. This compiled object is memoized (remember, there's one instance of a compiled definition PER MODEL CLASS).
219
+ # TODO: rework this with Model.finalize! support.
220
+ def self.definition
221
+ blocks = @saved_blocks.shift(@saved_blocks.size)
222
+
223
+ compiler = dsl_class.new(self, self.options)
224
+ compiler.parse(*blocks)
225
+
226
+ nil
227
+ end
228
+
229
+ attr_reader :lazy_attributes, :validating, :dumping
230
+
231
+
232
+ def initialize( data = nil)
233
+ @lazy_attributes = ::Hash.new
234
+ @validating = false
235
+ @dumping = false
236
+ if data
237
+ loaded = self.class.load( data )
238
+ @attributes = loaded.attributes
239
+ else
240
+ @attributes = ::Hash.new
241
+ end
242
+ end
243
+
244
+
245
+ # TODO: memoize validation results here, but only after rejiggering how we store the context.
246
+ # Two calls to validate() with different contexts should return get the same errors,
247
+ # but with their respective contexts.
248
+ def validate(context=Attributor::DEFAULT_ROOT_CONTEXT)
249
+
250
+ raise AttributorException, "validation conflict" if @validating
251
+ @validating = true
252
+
253
+ context = [context] if context.is_a? ::String
254
+
255
+ self.class.attributes.each_with_object(Array.new) do |(sub_attribute_name, sub_attribute), errors|
256
+ sub_context = self.class.generate_subcontext(context,sub_attribute_name)
257
+
258
+ value = self.send(sub_attribute_name)
259
+ if value.respond_to?(:validating) # really, it's a thing with sub-attributes
260
+ next if value.validating
261
+ end
262
+
263
+ errors.push *sub_attribute.validate(value, sub_context)
264
+ end
265
+ ensure
266
+ @validating = false
267
+ end
268
+
269
+
270
+ def attributes
271
+ @lazy_attributes.keys.each do |name|
272
+ self.send(name)
273
+ end
274
+ @attributes
275
+ end
276
+
277
+
278
+ def respond_to_missing?(name,*)
279
+ attribute_name = name.to_s
280
+ attribute_name.chomp!('=')
281
+
282
+ return true if self.class.attributes.key?(attribute_name.to_sym)
283
+
284
+ super
285
+ end
286
+
287
+
288
+ def method_missing(name, *args)
289
+ attribute_name = name.to_s
290
+ attribute_name.chomp!('=')
291
+
292
+ if self.class.attributes.has_key?(attribute_name.to_sym)
293
+ self.class.define_accessors(attribute_name)
294
+ return self.send(name, *args)
295
+ end
296
+
297
+ super
298
+ end
299
+
300
+
301
+ def dump(context: Attributor::DEFAULT_ROOT_CONTEXT, **opts)
302
+ return CIRCULAR_REFERENCE_MARKER if @dumping
303
+
304
+ @dumping = true
305
+
306
+ self.attributes.each_with_object({}) do |(name, value), result|
307
+ attribute = self.class.attributes[name]
308
+
309
+ result[name.to_sym] = attribute.dump(value, context: context + [name] )
310
+ end
311
+ ensure
312
+ @dumping = false
313
+ end
314
+
315
+ end
316
+ end
@@ -0,0 +1,19 @@
1
+ # Represents any Object
2
+
3
+ require_relative '../exceptions'
4
+
5
+ module Attributor
6
+
7
+ class Object
8
+ include Type
9
+
10
+ def self.native_type
11
+ return ::BasicObject
12
+ end
13
+
14
+ def self.example(context=nil, options:{})
15
+ 'An Object'
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ module Attributor
2
+ class String
3
+ include Type
4
+
5
+ def self.native_type
6
+ return ::String
7
+ end
8
+
9
+
10
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
11
+ String(value)
12
+ rescue
13
+ super
14
+ end
15
+
16
+ def self.example(context=nil, options:{})
17
+ if options[:regexp]
18
+ return options[:regexp].gen
19
+ else
20
+ return /\w+/.gen
21
+ end
22
+ end
23
+ end
24
+
25
+ end