Skip to content

Commit 174df99

Browse files
committed
Support non-string schema and instance keys
This deep stringifies input hashes in order to support symbol (and other non-string) keys. Symbol keys have been a common issue for people forever (as evidenced by `InvalidSymbolKey`) and I've been hesitant to address it because duplicating hashes and stringifying keys hurts performance, but I think it's probably worth it. The tricky thing here is that `insert_property_defaults`, `before_property_validation`, and `after_property_validation` need access to the actual hashes instead of the stringified version. To work around that, the original instance is passed around in `Context` and can be accessed by location using `original_instance`. Values passed in hooks need to be re-stringified in case the user added non-string keys. If this becomes a performance bottleneck, it may make sense to add a way to turn it off for people that know they're using string keys. Related: - #91 - #123
1 parent a4bccc2 commit 174df99

File tree

7 files changed

+95
-28
lines changed

7 files changed

+95
-28
lines changed

lib/json_schemer.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ class InvalidRefResolution < StandardError; end
6868
class InvalidRefPointer < StandardError; end
6969
class InvalidRegexpResolution < StandardError; end
7070
class InvalidFileURI < StandardError; end
71-
class InvalidSymbolKey < StandardError; end
7271
class InvalidEcmaRegexp < StandardError; end
7372

7473
VOCABULARIES = {

lib/json_schemer/draft202012/vocab/applicator.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,13 @@ def validate(instance, instance_location, keyword_location, context)
230230
return result(instance, instance_location, keyword_location, true) unless instance.is_a?(Hash)
231231

232232
if root.before_property_validation.any?
233+
original_instance = context.original_instance(instance_location)
233234
root.before_property_validation.each do |hook|
234235
parsed.each do |property, subschema|
235-
hook.call(instance, property, subschema.value, schema.value)
236+
hook.call(original_instance, property, subschema.value, schema.value)
236237
end
237238
end
239+
instance.replace(deep_stringify_keys(original_instance))
238240
end
239241

240242
evaluated_keys = []
@@ -248,11 +250,13 @@ def validate(instance, instance_location, keyword_location, context)
248250
end
249251

250252
if root.after_property_validation.any?
253+
original_instance = context.original_instance(instance_location)
251254
root.after_property_validation.each do |hook|
252255
parsed.each do |property, subschema|
253-
hook.call(instance, property, subschema.value, schema.value)
256+
hook.call(original_instance, property, subschema.value, schema.value)
254257
end
255258
end
259+
instance.replace(deep_stringify_keys(original_instance))
256260
end
257261

258262
result(instance, instance_location, keyword_location, nested.all?(&:valid), nested, :annotation => evaluated_keys)

lib/json_schemer/output.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,30 @@ def join_location(location, keyword)
2626
def fragment_encode(location)
2727
Format.percent_encode(location, FRAGMENT_ENCODE_REGEX)
2828
end
29+
30+
# :nocov:
31+
if Symbol.method_defined?(:name)
32+
def stringify(key)
33+
key.is_a?(Symbol) ? key.name : key.to_s
34+
end
35+
else
36+
def stringify(key)
37+
key.to_s
38+
end
39+
end
40+
# :nocov:
41+
42+
def deep_stringify_keys(obj)
43+
case obj
44+
when Hash
45+
obj.each_with_object({}) do |(key, value), out|
46+
out[stringify(key)] = deep_stringify_keys(value)
47+
end
48+
when Array
49+
obj.map { |item| deep_stringify_keys(item) }
50+
else
51+
obj
52+
end
53+
end
2954
end
3055
end

lib/json_schemer/result.rb

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ def classic
128128
end
129129
end
130130

131-
def insert_property_defaults
132-
instances = {}
131+
def insert_property_defaults(context)
132+
instance_locations = {}
133133

134134
results = [[self, true]]
135135
while (result, valid = results.pop)
@@ -145,18 +145,19 @@ def insert_property_defaults
145145
instance_location = Location.join(result.instance_location, property)
146146
keyword_location = Location.join(Location.join(result.keyword_location, property), default.keyword)
147147
default_result = default.validate(nil, instance_location, keyword_location, nil)
148-
instances[result.instance] ||= {}
149-
instances[result.instance][property] ||= []
150-
instances[result.instance][property] << [default_result, valid]
148+
instance_locations[result.instance_location] ||= {}
149+
instance_locations[result.instance_location][property] ||= []
150+
instance_locations[result.instance_location][property] << [default_result, valid]
151151
end
152152
end
153153
end
154154

155155
inserted = false
156156

157-
instances.each do |instance, properties|
157+
instance_locations.each do |instance_location, properties|
158+
original_instance = context.original_instance(instance_location)
158159
properties.each do |property, results_with_tree_validity|
159-
property_inserted = yield(instance, property, results_with_tree_validity)
160+
property_inserted = yield(original_instance, property, results_with_tree_validity)
160161
inserted ||= (property_inserted != false)
161162
end
162163
end

lib/json_schemer/schema.rb

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# frozen_string_literal: true
22
module JSONSchemer
33
class Schema
4-
Context = Struct.new(:dynamic_scope, :adjacent_results, :short_circuit)
4+
Context = Struct.new(:instance, :dynamic_scope, :adjacent_results, :short_circuit) do
5+
def original_instance(instance_location)
6+
Hana::Pointer.parse(Location.resolve(instance_location)).reduce(instance) do |obj, token|
7+
obj.fetch(obj.is_a?(Array) ? token.to_i : token)
8+
end
9+
end
10+
end
511

612
include Output
713
include Format::JSONPointer
@@ -52,7 +58,7 @@ def initialize(
5258
regexp_resolver: 'ruby',
5359
output_format: 'classic'
5460
)
55-
@value = value
61+
@value = deep_stringify_keys(value)
5662
@parent = parent
5763
@root = root
5864
@keyword = keyword
@@ -79,10 +85,10 @@ def valid?(instance)
7985

8086
def validate(instance, output_format: @output_format)
8187
instance_location = Location.root
82-
context = Context.new([], nil, (!insert_property_defaults && output_format == 'flag'))
83-
result = validate_instance(instance, instance_location, root_keyword_location, context)
84-
if insert_property_defaults && result.insert_property_defaults(&property_default_resolver)
85-
result = validate_instance(instance, instance_location, root_keyword_location, context)
88+
context = Context.new(instance, [], nil, (!insert_property_defaults && output_format == 'flag'))
89+
result = validate_instance(deep_stringify_keys(instance), instance_location, root_keyword_location, context)
90+
if insert_property_defaults && result.insert_property_defaults(context, &property_default_resolver)
91+
result = validate_instance(deep_stringify_keys(instance), instance_location, root_keyword_location, context)
8692
end
8793
result.output(output_format)
8894
end
@@ -271,7 +277,6 @@ def parse
271277
value.sort do |(keyword_a, _value_a), (keyword_b, _value_b)|
272278
keyword_order.fetch(keyword_a, last) <=> keyword_order.fetch(keyword_b, last)
273279
end.each do |keyword, value|
274-
raise InvalidSymbolKey, 'schemas must use string keys' unless keyword.is_a?(String)
275280
@parsed[keyword] ||= keywords.fetch(keyword, UNKNOWN_KEYWORD_CLASS).new(value, self, keyword)
276281
end
277282
end

test/json_schema_test_suite_test.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def test_json_schema_test_suite
5656
file_output[file] = JSON.parse(File.read(file)).map do |defn|
5757
tests, schema = defn.values_at('tests', 'schema')
5858

59+
schema = JSON.parse(JSON.generate(schema), :symbolize_names => true) if rand < 0.5
60+
5961
schemer = JSONSchemer::Schema.new(
6062
schema,
6163
:meta_schema => meta_schema,
@@ -70,6 +72,8 @@ def test_json_schema_test_suite
7072
tests.map do |test|
7173
data, valid = test.values_at('data', 'valid')
7274

75+
data = JSON.parse(JSON.generate(data), :symbolize_names => true) if rand < 0.5
76+
7377
assert_equal(
7478
valid,
7579
schemer.valid?(data),

test/json_schemer_test.rb

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,6 @@ def test_it_handles_json_strings
8686
refute(schema.valid?('1'))
8787
end
8888

89-
def test_it_checks_for_symbol_keys
90-
assert_raises(JSONSchemer::InvalidSymbolKey) { JSONSchemer.schema({ :type => 'integer' }) }
91-
schema = JSONSchemer.schema(
92-
{ '$ref' => 'http://example.com' },
93-
:ref_resolver => proc do |uri|
94-
{ :type => 'integer' }
95-
end
96-
)
97-
assert_raises(JSONSchemer::InvalidSymbolKey) { schema.valid?(1) }
98-
end
99-
10089
def test_it_returns_nested_errors
10190
root = {
10291
'type' => 'object',
@@ -345,4 +334,44 @@ def test_it_allows_validating_schemas
345334
assert_empty(JSONSchemer.validate_schema(valid_detected_draft4_schema).to_a)
346335
assert_equal([required_error], JSONSchemer.validate_schema(invalid_detected_draft4_schema).to_a)
347336
end
337+
338+
def test_non_string_keys
339+
schemer = JSONSchemer.schema({
340+
properties: {
341+
'title' => {
342+
type: 'string'
343+
},
344+
:description => {
345+
'type' => 'string'
346+
}
347+
}
348+
})
349+
assert(schemer.valid?({ title: 'some title' }))
350+
assert(schemer.valid?({ 'title' => 'some title' }))
351+
refute(schemer.valid?({ title: :sometitle }))
352+
refute(schemer.valid?({ 'title' => :sometitle }))
353+
assert(schemer.valid?({ description: 'some description' }))
354+
assert(schemer.valid?({ 'description' => 'some description' }))
355+
refute(schemer.valid?({ description: :somedescription }))
356+
refute(schemer.valid?({ 'description' => :somedescription }))
357+
358+
schemer = JSONSchemer.schema({
359+
'properties' => {
360+
'1' => {
361+
'const' => 'one'
362+
},
363+
2 => {
364+
:const => 'two'
365+
}
366+
}
367+
})
368+
assert(schemer.valid?({ 1 => 'one' }))
369+
assert(schemer.valid?({ '1' => 'one' }))
370+
refute(schemer.valid?({ 1 => 'neo' }))
371+
refute(schemer.valid?({ '1' => 'neo' }))
372+
assert(schemer.valid?({ 2 => 'two' }))
373+
assert(schemer.valid?({ '2' => 'two' }))
374+
refute(schemer.valid?({ 2 => 'tow' }))
375+
refute(schemer.valid?({ '2' => 'tow' }))
376+
end
348377
end

0 commit comments

Comments
 (0)