attributor 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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