dynamicschema 1.0.1 → 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.
@@ -0,0 +1,301 @@
1
+ require_relative 'base'
2
+ require_relative 'value'
3
+
4
+ module DynamicSchema
5
+ module Receiver
6
+ class Object < Base
7
+
8
+ def initialize( values = nil, schema:, converter:, defaults: true )
9
+ raise ArgumentError, 'The Receiver values must be a nil or a Hash.'\
10
+ unless values.nil? || ( values.respond_to?( :[] ) && values.respond_to?( :key? ) )
11
+
12
+ @values = {}
13
+ @schema = schema
14
+ @defaults = defaults
15
+ @defaults_assigned = {}
16
+
17
+ @converter = converter
18
+
19
+ @schema.each do | key, criteria |
20
+ name = criteria[ :as ] || key
21
+ if @defaults && criteria.key?( :default )
22
+ self.__send__( key, criteria[ :default ] )
23
+ @defaults_assigned[ key ] = true
24
+ end
25
+ self.__send__( key, values[ key ] ) if values && values.key?( key )
26
+ end
27
+
28
+ end
29
+
30
+ def empty?
31
+ @values.empty?
32
+ end
33
+
34
+ def to_h
35
+ recursive_to_h = ->( object ) do
36
+ case object
37
+ when ::NilClass
38
+ nil
39
+ when ::DynamicSchema::Receiver::Object
40
+ recursive_to_h.call( object.to_h )
41
+ when ::Hash
42
+ object.transform_values { | value | recursive_to_h.call( value ) }
43
+ when ::Array
44
+ object.map { | element | recursive_to_h.call( element ) }
45
+ else
46
+ object.respond_to?( :to_h ) ? object.to_h : object
47
+ end
48
+ end
49
+
50
+ recursive_to_h.call( @values )
51
+ end
52
+
53
+ def class
54
+ ::DynamicSchema::Receiver::Object
55
+ end
56
+
57
+ def is_a?( klass )
58
+ klass == ::DynamicSchema::Receiver::Object ||
59
+ klass == ::DynamicSchema::Receiver::Base ||
60
+ klass == ::BasicObject
61
+ end
62
+
63
+ alias :kind_of? :is_a?
64
+
65
+ def method_missing( method, *args, &block )
66
+ if @schema.key?( method )
67
+ criteria = @schema[ method ]
68
+ name = criteria[ :as ] || method
69
+ value = @values[ name ]
70
+
71
+ type = criteria[ :type ]
72
+ has_multiple_types = type.is_a?( ::Array ) && criteria[ :compiler ]
73
+
74
+ unless criteria[ :array ]
75
+ if has_multiple_types
76
+ value = __types( method, args, value: value, criteria: criteria, &block )
77
+ elsif type == ::Object
78
+ value = __object( method, args, value: value, criteria: criteria, &block )
79
+ else
80
+ value = __value( method, args, value: value, criteria: criteria, &block )
81
+ end
82
+ else
83
+ value = @defaults_assigned[ method ] ? ::Array.new : value || ::Array.new
84
+ if has_multiple_types
85
+ value = __types_array( method, args, value: value, criteria: criteria, &block )
86
+ elsif type == ::Object
87
+ value = __object_array( method, args, value: value, criteria: criteria, &block )
88
+ else
89
+ value = __values_array( method, args, value: value, criteria: criteria, &block )
90
+ end
91
+ end
92
+
93
+ @defaults_assigned[ method ] = false
94
+ @values[ name ] = value
95
+ else
96
+ ::Kernel.raise ::NoMethodError,
97
+ "There is no schema value or object '#{ method }' defined in this scope which includes: " \
98
+ "#{ @schema.keys.join( ', ' ) }."
99
+ end
100
+ end
101
+
102
+ def respond_to?( method, include_private = false )
103
+ @schema.key?( method ) || self.class.instance_methods.include?( method )
104
+ end
105
+
106
+ def respond_to_missing?( method, include_private = false )
107
+ @schema.key?( method ) || self.class.instance_methods.include?( method )
108
+ end
109
+
110
+ protected
111
+
112
+ def __process_arguments( name, arguments, required_arguments: )
113
+ count = arguments.count
114
+ required_arguments = [ required_arguments ].flatten if required_arguments
115
+ required_count = required_arguments&.length || 0
116
+ ::Kernel.raise ::ArgumentError,
117
+ "The attribute '#{ name }' requires #{ required_count } arguments " \
118
+ "(#{ required_arguments.join( ', ' ) }) but #{ count } was given." \
119
+ if count < required_count
120
+ ::Kernel.raise ::ArgumentError,
121
+ "The attribute '#{ name }' should have at most #{ required_count + 1 } arguments but " \
122
+ "#{ count } was given." \
123
+ if count > required_count + 1
124
+
125
+ result = {}
126
+
127
+ required_arguments&.each_with_index do | name, index |
128
+ result[ name.to_sym ] = arguments[ index ]
129
+ end
130
+ arguments.slice!( 0, required_arguments.length ) if required_arguments
131
+
132
+ last = arguments.last
133
+ case last
134
+ when ::Hash
135
+ result.merge( last )
136
+ when ::Array
137
+ last.map { | v | result.merge( v ) }
138
+ else
139
+ result
140
+ end
141
+ end
142
+
143
+ def __coerce_value( types, value )
144
+ return value unless types && !value.nil?
145
+
146
+ types = ::Kernel.method( :Array ).call( types )
147
+ result = nil
148
+
149
+ if value.respond_to?( :is_a? )
150
+ types.each do | type |
151
+ result = value.is_a?( type ) ? value : nil
152
+ break unless result.nil?
153
+ end
154
+ end
155
+
156
+ if result.nil?
157
+ types.each do | type |
158
+ result = @converter.convert( value, to: type ) rescue nil
159
+ break unless result.nil?
160
+ end
161
+ end
162
+
163
+ result
164
+ end
165
+
166
+ def __value( method, arguments, value:, criteria:, &block )
167
+ value = arguments.first
168
+ new_value = criteria[ :type ] ? __coerce_value( criteria[ :type ], value ) : value
169
+ new_value = new_value.nil? ? value : new_value
170
+ block ?
171
+ __value_block( method, value: new_value, criteria: criteria, &block ) :
172
+ new_value
173
+ end
174
+
175
+ def __values_array( method, arguments, value:, criteria:, &block )
176
+ values = [ arguments.first ].flatten
177
+ if type = criteria[ :type ]
178
+ values = values.map do | v |
179
+ new_value = __coerce_value( type, v )
180
+ new_value.nil? ? v : new_value
181
+ end
182
+ end
183
+ if block
184
+ values = values.map do | value |
185
+ __value_block( method, value: value, criteria: criteria, &block )
186
+ end
187
+ end
188
+ value.concat( values )
189
+ end
190
+
191
+ def __object( method, arguments, value:, criteria:, &block )
192
+ attributes = __process_arguments(
193
+ method, arguments,
194
+ required_arguments: criteria[ :arguments ]
195
+ )
196
+ if value.nil? || attributes&.any?
197
+ value =
198
+ Receiver::Object.new(
199
+ attributes,
200
+ converter: @converter,
201
+ schema: criteria[ :schema ] ||= criteria[ :compiler ].compiled,
202
+ defaults: @defaults
203
+ )
204
+ end
205
+ value.instance_eval( &block ) if block
206
+ value
207
+ end
208
+
209
+ def __object_array( method, arguments, value:, criteria:, &block )
210
+ attributes = __process_arguments(
211
+ method, arguments,
212
+ required_arguments: criteria[ :arguments ]
213
+ )
214
+ value.concat( [ attributes ].flatten.map { | a |
215
+ receiver = Receiver::Object.new(
216
+ a,
217
+ converter: @converter,
218
+ schema: criteria[ :schema ] ||= criteria[ :compiler ].compiled,
219
+ defaults: @defaults
220
+ )
221
+ receiver.instance_eval( &block ) if block
222
+ receiver
223
+ } )
224
+ end
225
+
226
+ def __types( method, arguments, value:, criteria:, &block )
227
+ argument = arguments.first
228
+ if block || argument.is_a?( ::Hash )
229
+ if block && !value.nil? && !value.is_a?( ::DynamicSchema::Receiver::Object )
230
+ value = nil
231
+ end
232
+ __object( method, arguments, value: value, criteria: criteria, &block )
233
+ else
234
+ scalar_criteria = criteria.merge( type: __non_object_types( criteria[ :type ] ) )
235
+ __value( method, arguments, value: value, criteria: scalar_criteria, &block )
236
+ end
237
+ end
238
+
239
+ def __types_array( method, arguments, value:, criteria:, &block )
240
+ items = [ arguments.first ].flatten
241
+ if block
242
+ __object_array( method, arguments, value: value, criteria: criteria, &block )
243
+ else
244
+ items.each do | item |
245
+ if item.is_a?( ::Hash )
246
+ __object_array( method, [ item ], value: value, criteria: criteria )
247
+ else
248
+ scalar_criteria = criteria.merge( type: __non_object_types( criteria[ :type ] ) )
249
+ __values_array( method, [ item ], value: value, criteria: scalar_criteria )
250
+ end
251
+ end
252
+ value
253
+ end
254
+ end
255
+
256
+ def __non_object_types( types )
257
+ types_array = types.is_a?( ::Array ) ? types : [ types ]
258
+ result = types_array.reject { | type | type == ::Object }
259
+ result.length == 1 ? result.first : result
260
+ end
261
+
262
+ def __value_block( method, value:, criteria:, &block )
263
+ type = criteria[ :type ]
264
+
265
+ # if the value type is a Proc the block is the value
266
+ return block if type == ::Proc
267
+
268
+ if value.nil?
269
+ if type.is_a?( ::Array )
270
+ if type.length == 1
271
+ type = type.first
272
+ else
273
+ ::Kernel.raise ::TypeError,
274
+ "An explicit value for '#{method}' is required when using a block " +
275
+ "because multiple types were specified."
276
+ end
277
+ end
278
+
279
+ case type
280
+ when ::Class
281
+ begin
282
+ value = type.new
283
+ rescue => error
284
+ ::Kernel.raise ::TypeError,
285
+ "An explicit value for '#{method}' is required because '#{type}' " +
286
+ "could not be constructed: #{error.message}."
287
+ end
288
+ else
289
+ ::Kernel.raise ::TypeError,
290
+ "An explicit value for '#{method}' is required because '#{type}' is " +
291
+ "not a Class."
292
+ end
293
+
294
+ end
295
+ ::DynamicSchema::Receiver::Value.new( value ).instance_eval( &block )
296
+ value
297
+ end
298
+
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,27 @@
1
+ require_relative 'base'
2
+
3
+ module DynamicSchema
4
+ module Receiver
5
+ class Value < Base
6
+ def initialize( target )
7
+ @target = target
8
+ end
9
+
10
+ def method_missing( method_name, *args, &block )
11
+ writer_method = :"#{ method_name }="
12
+ unless @target.respond_to?( writer_method )
13
+ ::Kernel.raise ::NoMethodError,
14
+ "The attribute '#{ method_name }' cannot be assigned because '#{ @target.class.name }' does not define '#{ writer_method }'."
15
+ end
16
+
17
+ if args.length != 1
18
+ ::Kernel.raise ::ArgumentError,
19
+ "The attribute '#{ method_name }' requires 1 argument but #{ args.length } was given."
20
+ end
21
+
22
+ @target.public_send( writer_method, args.first )
23
+ end
24
+ end
25
+ end
26
+ end
27
+
@@ -0,0 +1,203 @@
1
+ module DynamicSchema
2
+ class Struct
3
+ include Validator
4
+ class << self
5
+
6
+ def define( inherit: nil, &block )
7
+ builder = ::DynamicSchema.define( inherit: inherit, &block )
8
+ new( builder.schema )
9
+ end
10
+
11
+ def new( *arguments, &block )
12
+ unless self == ::DynamicSchema::Struct
13
+ super
14
+ else
15
+ __schema = arguments.first
16
+ ::Kernel.raise ::ArgumentError, "A Struct requires a schema." \
17
+ unless __schema
18
+ __converter = ::DynamicSchema::Converter
19
+
20
+ __compiled_schema = __compile_schema( __schema )
21
+ __klass = ::Class.new( self ) do
22
+
23
+ class << self
24
+ def build( attributes = {}, &block )
25
+ struct = new( attributes )
26
+ block.call( struct ) if block
27
+ struct
28
+ end
29
+
30
+ def build!( attributes = {}, &block )
31
+ struct = build( attributes, &block )
32
+ struct.validate!
33
+ struct
34
+ end
35
+ end
36
+
37
+ def initialize( attributes = nil )
38
+ @attributes = attributes&.dup || {}
39
+ @converted_attributes = {}
40
+ end
41
+
42
+ __compiled_schema.each do | property, criteria |
43
+
44
+ key = ( criteria[ :as ] || property ).to_sym
45
+ type = criteria[ :type ]
46
+ default = criteria[ :default ]
47
+ has_multiple_types = type.is_a?( ::Array ) && criteria[ :compiler ]
48
+
49
+ if has_multiple_types
50
+ define_method( property ) do
51
+ @converted_attributes.fetch( key ) do
52
+ value = @attributes[ key ]
53
+ value = default if value.nil?
54
+ schema = criteria[ :schema ] ||= ( criteria[ :compiler ]&.compiled )
55
+ klass = criteria[ :class ] ||= ( schema ? ::DynamicSchema::Struct.new( schema ) : nil )
56
+ @converted_attributes[ key ] =
57
+ if criteria[ :array ]
58
+ __convert_types_array( Array( value ), klass, criteria )
59
+ else
60
+ __convert_types( value, klass, criteria )
61
+ end
62
+ end
63
+ end
64
+ elsif type == ::Object
65
+ define_method( property ) do
66
+ @converted_attributes.fetch( key ) do
67
+ value = @attributes[ key ]
68
+ schema = criteria[ :schema ] ||= ( criteria[ :compiler ]&.compiled )
69
+ return value unless schema
70
+ klass = criteria[ :class ] ||= ::DynamicSchema::Struct.new( schema )
71
+ @converted_attributes[ key ] =
72
+ if criteria[ :array ]
73
+ Array( value || default ).map { | v | klass.build( v || {} ) }
74
+ else
75
+ klass.build( value || default )
76
+ end
77
+ end
78
+ end
79
+ elsif type
80
+ define_method( property ) do
81
+ @converted_attributes.fetch( key ) do
82
+ value = @attributes[ key ]
83
+ @converted_attributes[ key ] = criteria[ :array ] ?
84
+ Array( value || default ).map { | v | __convert( v, to: type ) } :
85
+ __convert( value || default, to: type )
86
+ end
87
+ end
88
+ else
89
+ define_method( property ) do
90
+ @attributes[ key ] || default
91
+ end
92
+ end
93
+
94
+ define_method( :"#{ property }=" ) do | value |
95
+ @converted_attributes.delete( key )
96
+ @attributes[ key ] = value
97
+ end
98
+
99
+ end
100
+
101
+ [ :validate!, :validate, :valid? ].each do | method |
102
+ define_method( method ) { super( @attributes ) }
103
+ end
104
+
105
+ def to_h
106
+ result = {}
107
+ self.compiled_schema.each do | property, criteria |
108
+ key = criteria[ :as ] || property
109
+ value = __object_to_h( self.send( property ) )
110
+ result[ key ] = value unless value.nil?
111
+ end
112
+ result
113
+ end
114
+
115
+ private
116
+
117
+ def compiled_schema
118
+ self.class.compiled_schema
119
+ end
120
+
121
+ def __object_to_h( object )
122
+ case object
123
+ when nil
124
+ nil
125
+ when ::Hash
126
+ object.transform_values { | v | __object_to_h( v ) } unless object.empty?
127
+ when ::Array
128
+ object.map { | e | __object_to_h( e ) } unless object.empty?
129
+ else
130
+ if object.respond_to?( :to_h )
131
+ __object_to_h( object.to_h )
132
+ else
133
+ object
134
+ end
135
+ end
136
+ end
137
+
138
+ def __convert( value, to: )
139
+ self.class.converter.convert( value, to: to ) { | v | to.new( v ) rescue nil }
140
+ end
141
+
142
+ def __convert_types( value, klass, criteria )
143
+ return nil if value.nil?
144
+ if value.is_a?( ::Hash )
145
+ klass ? klass.build( value ) : value
146
+ else
147
+ scalar_types = __non_object_types( criteria[ :type ] )
148
+ __convert( value, to: scalar_types )
149
+ end
150
+ end
151
+
152
+ def __convert_types_array( values, klass, criteria )
153
+ values.map { | v | __convert_types( v, klass, criteria ) }
154
+ end
155
+
156
+ def __non_object_types( types )
157
+ types_array = types.is_a?( ::Array ) ? types : [ types ]
158
+ result = types_array.reject { | type | type == ::Object }
159
+ result.length == 1 ? result.first : result
160
+ end
161
+
162
+ end
163
+ __klass.instance_variable_set( :@__compiled_schema, __compiled_schema.dup )
164
+ __klass.instance_variable_set( :@__converter, __converter )
165
+ __klass.class_eval( &block ) if block
166
+ __klass
167
+ end
168
+ end
169
+
170
+ def compiled_schema
171
+ @__compiled_schema ||
172
+ ( superclass.respond_to?( :compiled_schema ) && superclass.compiled_schema )
173
+ end
174
+
175
+ def converter
176
+ @__converter || ( superclass.respond_to?( :converter ) && superclass.converter )
177
+ end
178
+
179
+ private
180
+
181
+ def __compile_schema( schema )
182
+ case schema
183
+ when ::Proc
184
+ compiler = ::DynamicSchema::Compiler.new
185
+ compiler.compile( &schema )
186
+ compiler.compiled
187
+ when ::Hash
188
+ ::Kernel.raise ::ArgumentError,
189
+ "A Struct requires a schema but an empty Hash was given." \
190
+ if schema.empty?
191
+ schema
192
+ else
193
+ if schema.respond_to?( :compiled_schema, true )
194
+ schema.send( :compiled_schema )
195
+ else
196
+ ::Kernel.raise ::ArgumentError,
197
+ "A Struct requires a schema. I must be a Builder, Proc, or Hash."
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,139 @@
1
+ module DynamicSchema
2
+ module Validator
3
+ extend self
4
+
5
+ def validate!( values, schema: compiled_schema )
6
+ traverse_and_validate_values( values, schema: schema ) { | error |
7
+ raise error
8
+ }
9
+ end
10
+
11
+ def validate( values, schema: compiled_schema )
12
+ errors = []
13
+ traverse_and_validate_values( values, schema: schema ) { | error |
14
+ errors << error
15
+ }
16
+ errors
17
+ end
18
+
19
+ def valid?( values, schema: compiled_schema )
20
+ traverse_and_validate_values( values, schema: schema ) do
21
+ return false
22
+ end
23
+ true
24
+ end
25
+
26
+ protected
27
+
28
+ def compiled_schema
29
+ raise ::NotImplementedError,
30
+ "The DynamicSchema::Validator requires an includind class to implement the " +
31
+ "`compiled_schema` method."
32
+ end
33
+
34
+ def value_matches_types?( value, types )
35
+ type_match = false
36
+ Array( types ).each do | type |
37
+ type_match = value.is_a?( type )
38
+ break if type_match
39
+ end
40
+ type_match
41
+ end
42
+
43
+ def __validate_types( value, criteria, path, key, &block )
44
+ if value.is_a?( ::Hash )
45
+ traverse_and_validate_values(
46
+ value,
47
+ schema: criteria[ :schema ] ||= criteria[ :compiler ].compiled,
48
+ path: "#{ ( path || '' ) + ( path ? '/' : '' ) + key.to_s }",
49
+ &block
50
+ )
51
+ else
52
+ scalar_types = __non_object_types( criteria[ :type ] )
53
+ unless value_matches_types?( value, scalar_types )
54
+ block.call( IncompatibleTypeError.new( path: path, key: key, type: criteria[ :type ], value: value ) )
55
+ end
56
+ end
57
+ end
58
+
59
+ def __validate_types_array( values, criteria, path, key, &block )
60
+ values.each do | value |
61
+ __validate_types( value, criteria, path, key, &block )
62
+ end
63
+ end
64
+
65
+ def __non_object_types( types )
66
+ types_array = types.is_a?( ::Array ) ? types : [ types ]
67
+ types_array.reject { | type | type == ::Object }
68
+ end
69
+
70
+ def traverse_and_validate_values( values, schema:, path: nil, options: nil, &block )
71
+ path.chomp( '/' ) if path
72
+ unless values.respond_to?( :[] )
73
+ raise ArgumentError, "The values must respond_to `[]`."
74
+ end
75
+
76
+ schema.each do | key, criteria |
77
+ name = criteria[ :as ] || key
78
+ value = values[ name ]
79
+
80
+ if criteria[ :required ] && ( !value || ( value.respond_to?( :empty ) && value.empty? ) )
81
+ block.call( RequiredOptionError.new( path: path, key: key ) )
82
+ elsif criteria[ :in ]
83
+ Array( value ).each do | v |
84
+ unless criteria[ :in ].include?( v ) || v.nil?
85
+ block.call( InOptionError.new( path: path, key: key, option: criteria[ :in ], value: v ) )
86
+ end
87
+ end
88
+ elsif !criteria[ :default_assigned ] && !value.nil?
89
+ type = criteria[ :type ]
90
+ has_multiple_types = type.is_a?( ::Array ) && criteria[ :compiler ]
91
+
92
+ unless criteria[ :array ]
93
+ if has_multiple_types
94
+ __validate_types( value, criteria, path, key, &block )
95
+ elsif type == Object
96
+ traverse_and_validate_values(
97
+ values[ name ],
98
+ schema: criteria[ :schema ] ||= criteria[ :compiler ].compiled,
99
+ path: "#{ ( path || '' ) + ( path ? '/' : '' ) + key.to_s }",
100
+ &block
101
+ )
102
+ else
103
+ if type && value && !criteria[ :default_assigned ]
104
+ unless value_matches_types?( value, type )
105
+ block.call( IncompatibleTypeError.new( path: path, key: key, type: type, value: value ) )
106
+ end
107
+ end
108
+ end
109
+ else
110
+ if has_multiple_types
111
+ __validate_types_array( Array( value ), criteria, path, key, &block )
112
+ elsif type == Object
113
+ groups = Array( value )
114
+ groups.each do | group |
115
+ traverse_and_validate_values(
116
+ group,
117
+ schema: criteria[ :schema ] ||= criteria[ :compiler ].compiled,
118
+ path: "#{ ( path || '' ) + ( path ? '/' : '' ) + key.to_s }",
119
+ &block
120
+ )
121
+ end
122
+ else
123
+ if criteria[ :type ] && !criteria[ :default_assigned ]
124
+ value_array = Array( value )
125
+ value_array.each do | v |
126
+ unless value_matches_types?( v, criteria[ :type ] )
127
+ block.call( IncompatibleTypeError.new( path: path, key: key, type: criteria[ :type ], value: v ) )
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ nil
136
+ end
137
+
138
+ end
139
+ end
@@ -1,11 +1,16 @@
1
1
  require_relative 'dynamic_schema/errors'
2
+
3
+ require_relative 'dynamic_schema/validator'
4
+ require_relative 'dynamic_schema/converter'
2
5
  require_relative 'dynamic_schema/builder'
3
6
 
4
7
  require_relative 'dynamic_schema/definable'
5
8
  require_relative 'dynamic_schema/buildable'
6
9
 
10
+ require_relative 'dynamic_schema/struct'
11
+
7
12
  module DynamicSchema
8
- def self.define( schema = {}, &block )
9
- Builder.new( schema ).define( &block )
13
+ def self.define( inherit: nil, &block )
14
+ Builder.new.define( inherit: inherit, &block )
10
15
  end
11
16
  end