jsi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/jsi/schema.rb ADDED
@@ -0,0 +1,249 @@
1
+ require 'jsi/json/node'
2
+
3
+ module JSI
4
+ class Schema
5
+ include Memoize
6
+
7
+ def initialize(schema_object)
8
+ if schema_object.is_a?(JSI::Schema)
9
+ raise(TypeError, "will not instantiate Schema from another Schema: #{schema_object.pretty_inspect.chomp}")
10
+ elsif schema_object.is_a?(JSI::Base)
11
+ @schema_object = JSI.deep_stringify_symbol_keys(schema_object.deref)
12
+ @schema_node = @schema_object.instance
13
+ elsif schema_object.is_a?(JSI::JSON::HashNode)
14
+ @schema_object = nil
15
+ @schema_node = JSI.deep_stringify_symbol_keys(schema_object.deref)
16
+ elsif schema_object.respond_to?(:to_hash)
17
+ @schema_object = nil
18
+ @schema_node = JSI::JSON::Node.new_by_type(JSI.deep_stringify_symbol_keys(schema_object), [])
19
+ else
20
+ raise(TypeError, "cannot instantiate Schema from: #{schema_object.pretty_inspect.chomp}")
21
+ end
22
+ if @schema_object
23
+ define_singleton_method(:instance) { schema_node } # aka schema_object.instance
24
+ define_singleton_method(:schema) { schema_object.schema }
25
+ extend BaseHash
26
+ else
27
+ define_singleton_method(:[]) { |*a, &b| schema_node.public_send(:[], *a, &b) }
28
+ end
29
+ end
30
+ attr_reader :schema_node
31
+ def schema_object
32
+ @schema_object || @schema_node
33
+ end
34
+
35
+ def schema_id
36
+ @schema_id ||= begin
37
+ # start from schema_node and ascend parents looking for an 'id' property.
38
+ # append a fragment to that id (appending to an existing fragment if there
39
+ # is one) consisting of the path from that parent to our schema_node.
40
+ node_for_id = schema_node
41
+ path_from_id_node = []
42
+ done = false
43
+
44
+ while !done
45
+ # TODO: track what parents are schemas. somehow.
46
+ # look at 'id' if node_for_id is a schema, or the document root.
47
+ # decide whether to look at '$id' for all parent nodes or also just schemas.
48
+ if node_for_id.respond_to?(:to_hash)
49
+ if node_for_id.path.empty? || node_for_id.object_id == schema_node.object_id
50
+ # I'm only looking at 'id' for the document root and the schema node
51
+ # until I track what parents are schemas.
52
+ parent_id = node_for_id['$id'] || node_for_id['id']
53
+ else
54
+ # will look at '$id' everywhere since it is less likely to show up outside schemas than
55
+ # 'id', but it will be better to only look at parents that are schemas for this too.
56
+ parent_id = node_for_id['$id']
57
+ end
58
+ end
59
+
60
+ if parent_id || node_for_id.path.empty?
61
+ done = true
62
+ else
63
+ path_from_id_node.unshift(node_for_id.path.last)
64
+ node_for_id = node_for_id.parent_node
65
+ end
66
+ end
67
+ if parent_id
68
+ parent_auri = Addressable::URI.parse(parent_id)
69
+ else
70
+ node_for_id = schema_node.document_node
71
+ validator = ::JSON::Validator.new(node_for_id.content, nil)
72
+ # TODO not good instance_exec'ing into another library's ivars
73
+ parent_auri = validator.instance_exec { @base_schema }.uri
74
+ end
75
+ if parent_auri.fragment
76
+ # add onto the fragment
77
+ parent_id_path = ::JSON::Schema::Pointer.new(:fragment, '#' + parent_auri.fragment).reference_tokens
78
+ path_from_id_node = parent_id_path + path_from_id_node
79
+ parent_auri.fragment = nil
80
+ #else: no fragment so parent_id good as is
81
+ end
82
+
83
+ fragment = ::JSON::Schema::Pointer.new(:reference_tokens, path_from_id_node).fragment
84
+ schema_id = parent_auri.to_s + fragment
85
+
86
+ schema_id
87
+ end
88
+ end
89
+
90
+ def schema_class
91
+ JSI.class_for_schema(self)
92
+ end
93
+
94
+ def match_to_instance(instance)
95
+ # matching oneOf is good here. one schema for one instance.
96
+ # matching anyOf is okay. there could be more than one schema matched. it's often just one. if more
97
+ # than one is a match, the problems of allOf occur.
98
+ # matching allOf is questionable. all of the schemas must be matched but we just return the first match.
99
+ # there isn't really a better answer with the current implementation. merging the schemas together
100
+ # is a thought but is not practical.
101
+ %w(oneOf allOf anyOf).select { |k| schema_node[k].respond_to?(:to_ary) }.each do |someof_key|
102
+ schema_node[someof_key].map(&:deref).map do |someof_node|
103
+ someof_schema = self.class.new(someof_node)
104
+ if someof_schema.validate(instance)
105
+ return someof_schema.match_to_instance(instance)
106
+ end
107
+ end
108
+ end
109
+ return self
110
+ end
111
+
112
+ def subschema_for_property(property_name_)
113
+ memoize(:subschema_for_property, property_name_) do |property_name|
114
+ if schema_object['properties'].respond_to?(:to_hash) && schema_object['properties'][property_name].respond_to?(:to_hash)
115
+ self.class.new(schema_object['properties'][property_name])
116
+ else
117
+ if schema_object['patternProperties'].respond_to?(:to_hash)
118
+ _, pattern_schema_object = schema_object['patternProperties'].detect do |pattern, _|
119
+ property_name.to_s =~ Regexp.new(pattern) # TODO map pattern to ruby syntax
120
+ end
121
+ end
122
+ if pattern_schema_object
123
+ self.class.new(pattern_schema_object)
124
+ else
125
+ if schema_object['additionalProperties'].respond_to?(:to_hash)
126
+ self.class.new(schema_object['additionalProperties'])
127
+ else
128
+ nil
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ def subschema_for_index(index_)
136
+ memoize(:subschema_for_index, index_) do |index|
137
+ if schema_object['items'].respond_to?(:to_ary)
138
+ if index < schema_object['items'].size
139
+ self.class.new(schema_object['items'][index])
140
+ elsif schema_object['additionalItems'].respond_to?(:to_hash)
141
+ self.class.new(schema_object['additionalItems'])
142
+ end
143
+ elsif schema_object['items'].respond_to?(:to_hash)
144
+ self.class.new(schema_object['items'])
145
+ else
146
+ nil
147
+ end
148
+ end
149
+ end
150
+
151
+ def describes_array?
152
+ memoize(:describes_array?) do
153
+ schema_node['type'] == 'array' ||
154
+ schema_node['items'] ||
155
+ schema_node['additionalItems'] ||
156
+ schema_node['default'].respond_to?(:to_ary) || # TODO make sure this is right
157
+ (schema_node['enum'].respond_to?(:to_ary) && schema_node['enum'].all? { |enum| enum.respond_to?(:to_ary) }) ||
158
+ schema_node['maxItems'] ||
159
+ schema_node['minItems'] ||
160
+ schema_node.key?('uniqueItems') ||
161
+ schema_node['oneOf'].respond_to?(:to_ary) &&
162
+ schema_node['oneOf'].all? { |someof_node| self.class.new(someof_node).describes_array? } ||
163
+ schema_node['allOf'].respond_to?(:to_ary) &&
164
+ schema_node['allOf'].all? { |someof_node| self.class.new(someof_node).describes_array? } ||
165
+ schema_node['anyOf'].respond_to?(:to_ary) &&
166
+ schema_node['anyOf'].all? { |someof_node| self.class.new(someof_node).describes_array? }
167
+ end
168
+ end
169
+ def describes_hash?
170
+ memoize(:describes_hash?) do
171
+ schema_node['type'] == 'object' ||
172
+ schema_node['required'].respond_to?(:to_ary) ||
173
+ schema_node['properties'].respond_to?(:to_hash) ||
174
+ schema_node['additionalProperties'] ||
175
+ schema_node['patternProperties'] ||
176
+ schema_node['default'].respond_to?(:to_hash) ||
177
+ (schema_node['enum'].respond_to?(:to_ary) && schema_node['enum'].all? { |enum| enum.respond_to?(:to_hash) }) ||
178
+ schema_node['oneOf'].respond_to?(:to_ary) &&
179
+ schema_node['oneOf'].all? { |someof_node| self.class.new(someof_node).describes_hash? } ||
180
+ schema_node['allOf'].respond_to?(:to_ary) &&
181
+ schema_node['allOf'].all? { |someof_node| self.class.new(someof_node).describes_hash? } ||
182
+ schema_node['anyOf'].respond_to?(:to_ary) &&
183
+ schema_node['anyOf'].all? { |someof_node| self.class.new(someof_node).describes_hash? }
184
+ end
185
+ end
186
+
187
+ def described_hash_property_names
188
+ memoize(:described_hash_property_names) do
189
+ Set.new.tap do |property_names|
190
+ if schema_node['properties'].respond_to?(:to_hash)
191
+ property_names.merge(schema_node['properties'].keys)
192
+ end
193
+ if schema_node['required'].respond_to?(:to_ary)
194
+ property_names.merge(schema_node['required'].to_ary)
195
+ end
196
+ # we _could_ look at the properties of 'default' and each 'enum' but ... nah.
197
+ # we should look at dependencies (TODO).
198
+ %w(oneOf allOf anyOf).select { |k| schema_node[k].respond_to?(:to_ary) }.each do |schemas_key|
199
+ schema_node[schemas_key].map(&:deref).map do |someof_node|
200
+ property_names.merge(self.class.new(someof_node).described_hash_property_names)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ def fully_validate(instance)
208
+ ::JSON::Validator.fully_validate(schema_node.document, object_to_content(instance), fragment: schema_node.fragment)
209
+ end
210
+ def validate(instance)
211
+ ::JSON::Validator.validate(schema_node.document, object_to_content(instance), fragment: schema_node.fragment)
212
+ end
213
+ def validate!(instance)
214
+ ::JSON::Validator.validate!(schema_node.document, object_to_content(instance), fragment: schema_node.fragment)
215
+ end
216
+
217
+ def object_group_text
218
+ "schema_id=#{schema_id}"
219
+ end
220
+ def inspect
221
+ "\#<#{self.class.inspect} #{object_group_text} #{schema_object.inspect}>"
222
+ end
223
+ alias_method :to_s, :inspect
224
+ def pretty_print(q)
225
+ q.instance_exec(self) do |obj|
226
+ text "\#<#{obj.class.inspect} #{obj.object_group_text}"
227
+ group_sub {
228
+ nest(2) {
229
+ breakable ' '
230
+ pp obj.schema_object
231
+ }
232
+ }
233
+ breakable ''
234
+ text '>'
235
+ end
236
+ end
237
+ def fingerprint
238
+ {class: self.class, schema_node: schema_node}
239
+ end
240
+ include FingerprintHash
241
+
242
+ private
243
+ def object_to_content(object)
244
+ object = object.instance if object.is_a?(JSI::Base)
245
+ object = object.content if object.is_a?(JSI::JSON::Node)
246
+ object
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,83 @@
1
+ module JSI
2
+ # this is a ActiveRecord serialization class intended to store JSON in the
3
+ # database column and expose a ruby class once loaded on a model instance.
4
+ # this allows for better ruby idioms to access to properties, and definition
5
+ # of related methods on the loaded class.
6
+ #
7
+ # the first argument, `loaded_class`, is the class which will be used to
8
+ # instantiate the column data. properties of the loaded class will correspond
9
+ # to keys of the json object in the database.
10
+ #
11
+ # the column data may be either a single instance of the loaded class
12
+ # (represented as one json object) or an array of them (represented as a json
13
+ # array of json objects), indicated by the keyword argument `array`.
14
+ #
15
+ # the column behind the attribute may be an actual JSON column (postgres json
16
+ # or jsonb - hstore should work too if you only have string attributes) or a
17
+ # serialized string, indicated by the keyword argument `string`.
18
+ class ObjectJSONCoder
19
+ class Error < StandardError
20
+ end
21
+ class LoadError < Error
22
+ end
23
+ class DumpError < Error
24
+ end
25
+
26
+ def initialize(loaded_class, string: false, array: false, next_coder: nil)
27
+ @loaded_class = loaded_class
28
+ # this notes the order of the keys as they were in the json, used by dump_object to generate
29
+ # json that is equivalent to the json/jsonifiable that came in, so that AR's #changed_attributes
30
+ # can tell whether the attribute has been changed.
31
+ @loaded_class.send(:attr_accessor, :object_json_coder_keys_order)
32
+ @string = string
33
+ @array = array
34
+ @next_coder = next_coder
35
+ end
36
+
37
+ def load(column_data)
38
+ return nil if column_data.nil?
39
+ data = @string ? ::JSON.parse(column_data) : column_data
40
+ object = if @array
41
+ unless data.respond_to?(:to_ary)
42
+ raise TypeError, "expected array-like column data; got: #{data.class}: #{data.inspect}"
43
+ end
44
+ data.map { |el| load_object(el) }
45
+ else
46
+ load_object(data)
47
+ end
48
+ object = @next_coder.load(object) if @next_coder
49
+ object
50
+ end
51
+
52
+ def dump(object)
53
+ object = @next_coder.dump(object) if @next_coder
54
+ return nil if object.nil?
55
+ jsonifiable = begin
56
+ if @array
57
+ unless object.respond_to?(:to_ary)
58
+ raise DumpError, "expected array-like attribute; got: #{object.class}: #{object.inspect}"
59
+ end
60
+ object.map do |el|
61
+ dump_object(el)
62
+ end
63
+ else
64
+ dump_object(object)
65
+ end
66
+ end
67
+ @string ? ::JSON.generate(jsonifiable) : jsonifiable
68
+ end
69
+ end
70
+ # this is a ActiveRecord serialization class intended to store JSON in the
71
+ # database column and expose a given JSI::Base subclass once loaded
72
+ # on a model instance.
73
+ class SchemaInstanceJSONCoder < ObjectJSONCoder
74
+ private
75
+ def load_object(data)
76
+ @loaded_class.new(data)
77
+ end
78
+
79
+ def dump_object(object)
80
+ JSI::Typelike.as_json(object)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,30 @@
1
+ module JSI
2
+ # this is a ActiveRecord serialization class intended to store JSON in the
3
+ # database column and expose a Struct subclass once loaded on a model instance.
4
+ class StructJSONCoder < ObjectJSONCoder
5
+ private
6
+ def load_object(data)
7
+ if data.is_a?(Hash)
8
+ good_keys = @loaded_class.members.map(&:to_s)
9
+ bad_keys = data.keys - good_keys
10
+ unless bad_keys.empty?
11
+ raise LoadError, "expected keys #{good_keys}; got unrecognized keys: #{bad_keys}"
12
+ end
13
+ instance = @loaded_class.new(*@loaded_class.members.map { |m| data[m.to_s] })
14
+ instance.object_json_coder_keys_order = data.keys
15
+ instance
16
+ else
17
+ raise LoadError, "expected instance(s) of #{Hash}; got: #{data.class}: #{data.inspect}"
18
+ end
19
+ end
20
+
21
+ def dump_object(object)
22
+ if object.is_a?(@loaded_class)
23
+ keys = (object.object_json_coder_keys_order || []) | @loaded_class.members.map(&:to_s)
24
+ keys.map { |member| {member => object[member]} }.inject({}, &:update)
25
+ else
26
+ raise TypeError, "expected instance(s) of #{@loaded_class}; got: #{object.class}: #{object.inspect}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,164 @@
1
+ module JSI
2
+ module Typelike
3
+ def self.modified_copy(other, &block)
4
+ if other.respond_to?(:modified_copy)
5
+ other.modified_copy(&block)
6
+ else
7
+ return yield(other)
8
+ end
9
+ end
10
+
11
+ # I could require 'json/add/core' and use #as_json but I like this better.
12
+ def self.as_json(object, *opt)
13
+ if object.respond_to?(:to_hash)
14
+ object.map do |k, v|
15
+ unless k.is_a?(Symbol) || k.respond_to?(:to_str)
16
+ raise(TypeError, "json object (hash) cannot be keyed with: #{k.pretty_inspect.chomp}")
17
+ end
18
+ {k.to_s => as_json(v, *opt)}
19
+ end.inject({}, &:update)
20
+ elsif object.respond_to?(:to_ary)
21
+ object.map { |e| as_json(e, *opt) }
22
+ elsif [String, TrueClass, FalseClass, NilClass, Numeric].any? { |c| object.is_a?(c) }
23
+ object
24
+ elsif object.is_a?(Symbol)
25
+ object.to_s
26
+ elsif object.is_a?(Set)
27
+ as_json(object.to_a, *opt)
28
+ elsif object.respond_to?(:as_json)
29
+ as_json(object.as_json(*opt), *opt)
30
+ else
31
+ raise(TypeError, "cannot express object as json: #{object.pretty_inspect.chomp}")
32
+ end
33
+ end
34
+ end
35
+ module Hashlike
36
+ # safe methods which can be delegated to #to_hash (which the includer is assumed to have defined).
37
+ # 'safe' means, in this context, nondestructive - methods which do not modify the receiver.
38
+
39
+ # methods which do not need to access the value.
40
+ SAFE_KEY_ONLY_METHODS = %w(each_key empty? has_key? include? key? keys length member? size)
41
+ SAFE_KEY_VALUE_METHODS = %w(< <= > >= any? assoc compact dig each_pair each_value fetch fetch_values has_value? invert key merge rassoc reject select to_h to_proc transform_values value? values values_at)
42
+ DESTRUCTIVE_METHODS = %w(clear delete delete_if keep_if reject! replace select! shift)
43
+ # these return a modified copy
44
+ safe_modified_copy_methods = %w(compact merge)
45
+ # select and reject will return a modified copy but need the yielded block variable value from #[]
46
+ safe_kv_block_modified_copy_methods = %w(select reject)
47
+ SAFE_METHODS = SAFE_KEY_ONLY_METHODS | SAFE_KEY_VALUE_METHODS
48
+ safe_to_hash_methods = SAFE_METHODS - safe_modified_copy_methods - safe_kv_block_modified_copy_methods
49
+ safe_to_hash_methods.each do |method_name|
50
+ define_method(method_name) { |*a, &b| to_hash.public_send(method_name, *a, &b) }
51
+ end
52
+ safe_modified_copy_methods.each do |method_name|
53
+ define_method(method_name) do |*a, &b|
54
+ JSI::Typelike.modified_copy(self) do |object_to_modify|
55
+ object_to_modify.public_send(method_name, *a, &b)
56
+ end
57
+ end
58
+ end
59
+ safe_kv_block_modified_copy_methods.each do |method_name|
60
+ define_method(method_name) do |*a, &b|
61
+ JSI::Typelike.modified_copy(self) do |object_to_modify|
62
+ object_to_modify.public_send(method_name, *a) do |k, _v|
63
+ b.call(k, self[k])
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def inspect
70
+ object_group_text = respond_to?(:object_group_text) ? ' ' + self.object_group_text : ''
71
+ "\#{<#{self.class.to_s}#{object_group_text}>#{empty? ? '' : ' '}#{self.map { |k, v| "#{k.inspect} => #{v.inspect}" }.join(', ')}}"
72
+ end
73
+
74
+ def to_s
75
+ inspect
76
+ end
77
+
78
+ def pretty_print(q)
79
+ q.instance_exec(self) do |obj|
80
+ object_group_text = obj.respond_to?(:object_group_text) ? ' ' + obj.object_group_text : ''
81
+ text "\#{<#{obj.class.to_s}#{object_group_text}>"
82
+ group_sub {
83
+ nest(2) {
84
+ breakable(obj.any? { true } ? ' ' : '')
85
+ seplist(obj, nil, :each_pair) { |k, v|
86
+ group {
87
+ pp k
88
+ text ' => '
89
+ pp v
90
+ }
91
+ }
92
+ }
93
+ }
94
+ breakable ''
95
+ text '}'
96
+ end
97
+ end
98
+ end
99
+ module Arraylike
100
+ # safe methods which can be delegated to #to_ary (which the includer is assumed to have defined).
101
+ # 'safe' means, in this context, nondestructive - methods which do not modify the receiver.
102
+
103
+ # methods which do not need to access the element.
104
+ SAFE_INDEX_ONLY_METHODS = %w(each_index empty? length size)
105
+ # there are some ambiguous ones that are omitted, like #sort, #map / #collect.
106
+ SAFE_INDEX_ELEMENT_METHODS = %w(| & * + - <=> abbrev assoc at bsearch bsearch_index combination compact count cycle dig drop drop_while fetch find_index first include? index join last pack permutation product reject repeated_combination repeated_permutation reverse reverse_each rindex rotate sample select shelljoin shuffle slice sort take take_while transpose uniq values_at zip)
107
+ DESTRUCTIVE_METHODS = %w(<< clear collect! compact! concat delete delete_at delete_if fill flatten! insert keep_if map! pop push reject! replace reverse! rotate! select! shift shuffle! slice! sort! sort_by! uniq! unshift)
108
+
109
+ # methods (well, method) that returns a modified copy and doesn't need any handling of block variable(s)
110
+ safe_modified_copy_methods = %w(compact)
111
+
112
+ # methods that return a modified copy and do need handling of block variables
113
+ safe_el_block_methods = %w(reject select)
114
+
115
+ SAFE_METHODS = SAFE_INDEX_ONLY_METHODS | SAFE_INDEX_ELEMENT_METHODS
116
+ safe_to_ary_methods = SAFE_METHODS - safe_modified_copy_methods - safe_el_block_methods
117
+ safe_to_ary_methods.each do |method_name|
118
+ define_method(method_name) { |*a, &b| to_ary.public_send(method_name, *a, &b) }
119
+ end
120
+ safe_modified_copy_methods.each do |method_name|
121
+ define_method(method_name) do |*a, &b|
122
+ JSI::Typelike.modified_copy(self) do |object_to_modify|
123
+ object_to_modify.public_send(method_name, *a, &b)
124
+ end
125
+ end
126
+ end
127
+ safe_el_block_methods.each do |method_name|
128
+ define_method(method_name) do |*a, &b|
129
+ JSI::Typelike.modified_copy(self) do |object_to_modify|
130
+ i = 0
131
+ object_to_modify.public_send(method_name, *a) do |_e|
132
+ b.call(self[i]).tap { i += 1 }
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ def inspect
139
+ object_group_text = respond_to?(:object_group_text) ? ' ' + self.object_group_text : ''
140
+ "\#[<#{self.class.to_s}#{object_group_text}>#{empty? ? '' : ' '}#{self.map { |e| e.inspect }.join(', ')}]"
141
+ end
142
+
143
+ def to_s
144
+ inspect
145
+ end
146
+
147
+ def pretty_print(q)
148
+ q.instance_exec(self) do |obj|
149
+ object_group_text = obj.respond_to?(:object_group_text) ? ' ' + obj.object_group_text : ''
150
+ text "\#[<#{obj.class.to_s}#{object_group_text}>"
151
+ group_sub {
152
+ nest(2) {
153
+ breakable(obj.any? { true } ? ' ' : '')
154
+ seplist(obj, nil, :each) { |e|
155
+ pp e
156
+ }
157
+ }
158
+ }
159
+ breakable ''
160
+ text ']'
161
+ end
162
+ end
163
+ end
164
+ end