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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -0
- data/Guardfile +12 -0
- data/LICENSE +22 -0
- data/README.md +62 -0
- data/Rakefile +28 -0
- data/attributor.gemspec +40 -0
- data/lib/attributor.rb +89 -0
- data/lib/attributor/attribute.rb +271 -0
- data/lib/attributor/attribute_resolver.rb +116 -0
- data/lib/attributor/dsl_compiler.rb +106 -0
- data/lib/attributor/exceptions.rb +38 -0
- data/lib/attributor/extensions/randexp.rb +10 -0
- data/lib/attributor/type.rb +117 -0
- data/lib/attributor/types/boolean.rb +26 -0
- data/lib/attributor/types/collection.rb +135 -0
- data/lib/attributor/types/container.rb +42 -0
- data/lib/attributor/types/csv.rb +10 -0
- data/lib/attributor/types/date_time.rb +36 -0
- data/lib/attributor/types/file_upload.rb +11 -0
- data/lib/attributor/types/float.rb +27 -0
- data/lib/attributor/types/hash.rb +337 -0
- data/lib/attributor/types/ids.rb +26 -0
- data/lib/attributor/types/integer.rb +63 -0
- data/lib/attributor/types/model.rb +316 -0
- data/lib/attributor/types/object.rb +19 -0
- data/lib/attributor/types/string.rb +25 -0
- data/lib/attributor/types/struct.rb +50 -0
- data/lib/attributor/types/tempfile.rb +36 -0
- data/lib/attributor/version.rb +3 -0
- data/spec/attribute_resolver_spec.rb +227 -0
- data/spec/attribute_spec.rb +597 -0
- data/spec/attributor_spec.rb +25 -0
- data/spec/dsl_compiler_spec.rb +130 -0
- data/spec/spec_helper.rb +30 -0
- data/spec/support/models.rb +81 -0
- data/spec/support/types.rb +21 -0
- data/spec/type_spec.rb +134 -0
- data/spec/types/boolean_spec.rb +85 -0
- data/spec/types/collection_spec.rb +286 -0
- data/spec/types/container_spec.rb +49 -0
- data/spec/types/csv_spec.rb +17 -0
- data/spec/types/date_time_spec.rb +90 -0
- data/spec/types/file_upload_spec.rb +6 -0
- data/spec/types/float_spec.rb +78 -0
- data/spec/types/hash_spec.rb +372 -0
- data/spec/types/ids_spec.rb +32 -0
- data/spec/types/integer_spec.rb +151 -0
- data/spec/types/model_spec.rb +401 -0
- data/spec/types/string_spec.rb +55 -0
- data/spec/types/struct_spec.rb +189 -0
- data/spec/types/tempfile_spec.rb +6 -0
- 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
|