schemurai 2.0.0 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2b340649fb4d070d05390ec257487c1f37e45287ef16325f0cfe3e4fb10ae72
4
- data.tar.gz: e9e93301abe43e21ff297b66a3d9cb4d43376c8ac2b80ee4e650c1705f5dbc99
3
+ metadata.gz: 01b0c89354b90232337525b7fe75c063053fecd2965036bb7ff600d2b4e823cb
4
+ data.tar.gz: f45549ac8cf41eae0c2b28220ca93fe87042cec2aa7c7926382a00f8f65c105d
5
5
  SHA512:
6
- metadata.gz: a134b68cc4c6a2fc991822d63f841e6bec085a1c33109aca32e75feebd4cd95ca28dad001835591b200515170aa44bbf2c32275a610b2f7acc878d41f8a46bab
7
- data.tar.gz: f9fd8b9be4432b61d153be039e39e59b0749ada777e15ebbebf007c69d237d356f5b6feea1c790d8e51201e6bedda0cc26e0c533f499e49062e461f78fa03bd3
6
+ metadata.gz: 618d494971d7a1745075dae9b9e6155562f9c0f0fb058e923cd457f26bb062be2858db9f2ffaf711d035ee83490179e005869106f3ae29ef0c8011512bed2af9
7
+ data.tar.gz: 6ecd49a7320b6b936067808a2a0bd07d6e497418427d0e1612d624dd703a71c6eae99f9ae27ab0a94c2946c3a6495fdc2c11bf665b7d59a9fad628f285af0f5e
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Schemurai
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/schemurai.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/schemurai)
4
+ [![test / rspec](https://github.com/oakcask/schemurai/actions/workflows/test--rspec.yaml/badge.svg)](https://github.com/oakcask/schemurai/actions/workflows/test--rspec.yaml)
5
+ [![lint / rubocop](https://github.com/oakcask/schemurai/actions/workflows/lint--rubocop.yaml/badge.svg)](https://github.com/oakcask/schemurai/actions/workflows/lint--rubocop.yaml)
6
+ [![lint / rbs](https://github.com/oakcask/schemurai/actions/workflows/lint--rbs.yaml/badge.svg)](https://github.com/oakcask/schemurai/actions/workflows/lint--rbs.yaml)
7
+
3
8
  A small, light-weight-dependency JSON Schema validator for Ruby supporting Draft 7,
4
9
  Draft 2019-09, and Draft 2020-12. It covers the required cases in the official
5
10
  JSON-Schema-Test-Suite, as well as the applicable optional tests for numeric
@@ -7,6 +12,8 @@ precision, ECMA-262 regular expressions, content validation, anchors, and dynami
7
12
  references. The dialect is selected from the schema's `$schema` URI; schemas that
8
13
  omit `$schema` use Draft 7 for compatibility.
9
14
 
15
+ Schemurai ships RBS declarations for its public API in `sig/schemurai.rbs`.
16
+
10
17
  ```ruby
11
18
  require "schemurai"
12
19
 
@@ -116,6 +123,38 @@ validation for `contentEncoding` and `contentMediaType` with `content: true`.
116
123
  Enable optional format assertions with `format: true`; support for each format is
117
124
  listed separately below.
118
125
 
126
+ ### Validation errors
127
+
128
+ ```ruby
129
+ result = Schemurai.validate(
130
+ { "$ref" => "https://example.test/positive" },
131
+ -1,
132
+ schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
133
+ )
134
+
135
+ validation_error = result.errors[0]
136
+ p validation_error.keyword # => "minimum"
137
+ p validation_error.instance_path # => ""
138
+ p validation_error.schema_path # => "/$ref/minimum"
139
+ p validation_error.message # => "number must be greater than or equal to 1"
140
+ ```
141
+
142
+ `Result#errors` contains `Schemurai::ValidationError` objects.
143
+ Check the following attributes to investigate schema errors:
144
+
145
+ - `keyword` identifies the failed JSON Schema keyword. Schemurai uses
146
+ `falseSchema` for a boolean `false` schema, which has no keyword of its own.
147
+ - `instance_path` is a JSON Pointer to the value that failed validation.
148
+ - `schema_path` is a JSON Pointer to the schema location that produced the
149
+ error.
150
+ - `message` is a human-readable `String` describing the failure.
151
+
152
+ An empty path points to the instance or schema root. Error order is stable.
153
+ The presence and type of `message` are part of the public API, but its exact
154
+ wording is not. Use `keyword`, `instance_path`, and `schema_path`, rather than
155
+ matching `message`, when handling errors programmatically. `ValidationError#to_h`
156
+ returns these four attributes as a Hash.
157
+
119
158
  ### Thread / Ractor support
120
159
 
121
160
  To share a registry between threads or Ractors, finish registering schemas and
@@ -248,8 +287,7 @@ ruby script/oracle-cases --summary
248
287
  ```
249
288
 
250
289
  The serialized oracle tools are `script/oracle-runner` and
251
- `script/oracle-compare`. Ruby interpreter and YJIT baseline instructions are in
252
- [`benchmark/baselines/README.md`](benchmark/baselines/README.md).
290
+ `script/oracle-compare`.
253
291
 
254
292
  Run the linter with:
255
293
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ module Internal
5
+ # Builds validation messages shared by every evaluator backend.
6
+ module ErrorMessage
7
+ module_function def false_schema = "value is not allowed by this schema"
8
+
9
+ module_function def type(expected, value)
10
+ expected = Array(expected).map { |name| name.inspect }.join(" or ")
11
+ "value has type #{json_type(value).inspect}, but must have type #{expected}"
12
+ end
13
+
14
+ module_function def enum = "value must equal one of the values defined by `enum`"
15
+ module_function def const = "value must equal the value defined by `const`"
16
+
17
+ module_function def numeric_limit(keyword, limit)
18
+ comparison = {
19
+ "maximum" => "less than or equal to",
20
+ "minimum" => "greater than or equal to",
21
+ "exclusiveMaximum" => "less than",
22
+ "exclusiveMinimum" => "greater than"
23
+ }.fetch(keyword)
24
+ "number must be #{comparison} #{display_number(limit)}"
25
+ end
26
+
27
+ module_function def multiple_of(divisor) = "number must be a multiple of #{display_number(divisor)}"
28
+
29
+ module_function def size(keyword, limit, actual)
30
+ subject, unit, comparison = {
31
+ "maxLength" => ["string", "characters", "at most"],
32
+ "minLength" => ["string", "characters", "at least"],
33
+ "maxItems" => ["array", "items", "at most"],
34
+ "minItems" => ["array", "items", "at least"],
35
+ "maxProperties" => ["object", "properties", "at most"],
36
+ "minProperties" => ["object", "properties", "at least"]
37
+ }.fetch(keyword)
38
+ unit = {"characters" => "character", "items" => "item", "properties" => "property"}.fetch(unit) if limit == 1
39
+ "#{subject} must contain #{comparison} #{limit} #{unit} (found #{actual})"
40
+ end
41
+
42
+ module_function def pattern(pattern) = "string must match pattern #{pattern.inspect}"
43
+ module_function def invalid_pattern(pattern) = "schema pattern #{pattern.inspect} is not a valid regular expression"
44
+ module_function def format(name) = "string must match the #{name} format"
45
+ module_function def content_encoding = "string must be valid base64"
46
+ module_function def content_media_type = "string must contain valid JSON"
47
+ module_function def unique_items = "array items must be unique"
48
+
49
+ module_function def contains(actual, minimum, maximum)
50
+ expected = if maximum.infinite?
51
+ "at least #{minimum}"
52
+ elsif minimum == maximum
53
+ "exactly #{minimum}"
54
+ elsif minimum.zero?
55
+ "at most #{maximum}"
56
+ else
57
+ "between #{minimum} and #{maximum}"
58
+ end
59
+ "array must contain #{expected} items matching `contains` (found #{actual})"
60
+ end
61
+
62
+ module_function def required(name) = "object is missing required property #{name.inspect}"
63
+
64
+ module_function def dependent_required(name, required_name)
65
+ "property #{required_name.inspect} is required when property #{name.inspect} is present"
66
+ end
67
+
68
+ module_function def any_of = "value must match at least one subschema"
69
+ module_function def one_of(matches) = "value must match exactly one subschema (matched #{matches})"
70
+ module_function def not = "value must not match the subschema"
71
+
72
+ module_function def json_type(value)
73
+ case value
74
+ when nil then "null"
75
+ when true, false then "boolean"
76
+ when Hash then "object"
77
+ when Array then "array"
78
+ when String then "string"
79
+ when Numeric
80
+ if value.is_a?(Complex)
81
+ value.class.name
82
+ elsif value.finite? && value.to_i == value
83
+ "integer"
84
+ else
85
+ "number"
86
+ end
87
+ else
88
+ value.class.name
89
+ end
90
+ end
91
+ private_class_method :json_type
92
+
93
+ module_function def display_number(value)
94
+ rational = value.is_a?(Rational) ? value : Rational(value.to_s)
95
+ return rational.numerator.to_s if rational.denominator == 1
96
+
97
+ rational.to_f.to_s
98
+ end
99
+ private_class_method :display_number
100
+ end
101
+ end
102
+ end
@@ -34,10 +34,27 @@ module Schemurai
34
34
 
35
35
  def merge(other)
36
36
  return self.class.invalid unless valid? && other.valid?
37
+ return self if other.evaluated_properties.empty? && other.evaluated_items.empty?
38
+ return other if evaluated_properties.empty? && evaluated_items.empty?
39
+
40
+ properties = if evaluated_properties.empty?
41
+ other.evaluated_properties
42
+ elsif other.evaluated_properties.empty?
43
+ evaluated_properties
44
+ else
45
+ evaluated_properties | other.evaluated_properties
46
+ end
47
+ items = if evaluated_items.empty?
48
+ other.evaluated_items
49
+ elsif other.evaluated_items.empty?
50
+ evaluated_items
51
+ else
52
+ evaluated_items | other.evaluated_items
53
+ end
37
54
 
38
55
  self.class.valid(
39
- evaluated_properties: (evaluated_properties | other.evaluated_properties),
40
- evaluated_items: (evaluated_items | other.evaluated_items)
56
+ evaluated_properties: properties,
57
+ evaluated_items: items
41
58
  )
42
59
  end
43
60
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "base64"
5
5
  require_relative "evaluation"
6
+ require_relative "error_message"
6
7
 
7
8
  module Schemurai
8
9
  module Internal
@@ -333,7 +334,7 @@ module Schemurai
333
334
  schema = node.schema
334
335
  return Evaluation.valid if schema == true
335
336
  if schema == false
336
- add_error("falseSchema", "boolean schema is false", append_keyword: false)
337
+ add_error("falseSchema", append_keyword: false) { ErrorMessage.false_schema }
337
338
  return Evaluation.invalid
338
339
  end
339
340
  return Evaluation.valid unless schema.is_a?(Hash)
@@ -442,15 +443,15 @@ module Schemurai
442
443
  types = Array(schema["type"])
443
444
  return if types.any? { |type| type?(value, type) }
444
445
 
445
- add_error("type", "expected #{types.join(" or ")}")
446
+ add_error("type") { ErrorMessage.type(types, value) }
446
447
  end
447
448
 
448
449
  private def check_enum(schema, value)
449
450
  if schema.key?("enum") && !schema["enum"].any? { |candidate| json_equal?(candidate, value) }
450
- add_error("enum", "value is not in enum")
451
+ add_error("enum") { ErrorMessage.enum }
451
452
  end
452
453
  if schema.key?("const") && !json_equal?(schema["const"], value)
453
- add_error("const", "value does not equal const")
454
+ add_error("const") { ErrorMessage.const }
454
455
  end
455
456
  end
456
457
 
@@ -472,7 +473,7 @@ module Schemurai
472
473
  matches << result if result.valid?
473
474
  end
474
475
  if matches.empty?
475
- add_error("anyOf", "no subschema matched")
476
+ add_error("anyOf") { ErrorMessage.any_of }
476
477
  else
477
478
  matches.each { |result| evaluation = evaluation.merge(result) }
478
479
  end
@@ -487,12 +488,12 @@ module Schemurai
487
488
  if matches.length == 1
488
489
  evaluation = evaluation.merge(matches.first)
489
490
  else
490
- add_error("oneOf", "expected exactly one match, got #{matches.length}")
491
+ add_error("oneOf") { ErrorMessage.one_of(matches.length) }
491
492
  end
492
493
  end
493
494
 
494
495
  if schema.key?("not") && trial_at(node.child("not"), value, MISSING_SEGMENT, "not").valid?
495
- add_error("not", "subschema matched")
496
+ add_error("not") { ErrorMessage.not }
496
497
  end
497
498
 
498
499
  if schema.key?("if")
@@ -516,14 +517,14 @@ module Schemurai
516
517
 
517
518
  divisor = decimal(schema["multipleOf"])
518
519
  valid = divisor.positive? && decimal(value).remainder(divisor).zero?
519
- add_error("multipleOf", "number is not a multiple") unless valid
520
+ add_error("multipleOf") { ErrorMessage.multiple_of(schema["multipleOf"]) } unless valid
520
521
  end
521
522
 
522
523
  private def compare(schema, keyword, value)
523
524
  return unless schema.key?(keyword)
524
525
  return if yield(decimal(value), decimal(schema[keyword]))
525
526
 
526
- add_error(keyword, "numeric limit was exceeded")
527
+ add_error(keyword) { ErrorMessage.numeric_limit(keyword, schema[keyword]) }
527
528
  end
528
529
 
529
530
  private def check_string(node, value)
@@ -534,14 +535,14 @@ module Schemurai
534
535
 
535
536
  if schema.key?("pattern")
536
537
  matched = ecma_regexp(schema["pattern"]).match?(value)
537
- add_error("pattern", "string does not match pattern") unless matched
538
+ add_error("pattern") { ErrorMessage.pattern(schema["pattern"]) } unless matched
538
539
  end
539
540
  if format_asserted?(node)
540
- add_error("format", "string is not a valid #{node.format.name}") unless node.format.call(value)
541
+ add_error("format") { ErrorMessage.format(node.format.name) } unless node.format.call(value)
541
542
  end
542
543
  check_content(schema, value) if @validate_content
543
544
  rescue RegexpError
544
- add_error("pattern", "invalid regular expression")
545
+ add_error("pattern") { ErrorMessage.invalid_pattern(schema["pattern"]) }
545
546
  end
546
547
 
547
548
  private def format_asserted?(node)
@@ -558,7 +559,7 @@ module Schemurai
558
559
  duplicate = value.each_with_index.any? do |item, index|
559
560
  value[0...index].any? { |previous| json_equal?(previous, item) }
560
561
  end
561
- add_error("uniqueItems", "array items are not unique") if duplicate
562
+ add_error("uniqueItems") { ErrorMessage.unique_items } if duplicate
562
563
  end
563
564
 
564
565
  prefix_items = schema["prefixItems"]
@@ -600,7 +601,7 @@ module Schemurai
600
601
  minimum = schema.fetch("minContains", 1)
601
602
  maximum = schema.fetch("maxContains", Float::INFINITY)
602
603
  unless matched.length.between?(minimum, maximum)
603
- add_error("contains", "matched #{matched.length} array items")
604
+ add_error("contains") { ErrorMessage.contains(matched.length, minimum, maximum) }
604
605
  end
605
606
  evaluated.concat(matched)
606
607
  end
@@ -623,7 +624,7 @@ module Schemurai
623
624
  limit(schema, "minProperties", value.length) { |actual, expected| actual >= expected }
624
625
 
625
626
  Array(schema["required"]).each do |name|
626
- add_error("required", "required property #{name.inspect} is missing") unless value.key?(name)
627
+ add_error("required") { ErrorMessage.required(name) } unless value.key?(name)
627
628
  end
628
629
 
629
630
  properties = schema.fetch("properties", {})
@@ -657,7 +658,7 @@ module Schemurai
657
658
  next unless value.key?(name)
658
659
  if dependency.is_a?(Array)
659
660
  dependency.each do |required_name|
660
- add_error("dependencies", "property #{required_name.inspect} is required by #{name.inspect}") unless value.key?(required_name)
661
+ add_error("dependencies") { ErrorMessage.dependent_required(name, required_name) } unless value.key?(required_name)
661
662
  end
662
663
  else
663
664
  result = evaluate_at(node.child("dependencies", name), value, MISSING_SEGMENT, "dependencies", name)
@@ -669,7 +670,7 @@ module Schemurai
669
670
  next unless value.key?(name)
670
671
  required_names.each do |required_name|
671
672
  unless value.key?(required_name)
672
- add_error("dependentRequired", "property #{required_name.inspect} is required by #{name.inspect}")
673
+ add_error("dependentRequired") { ErrorMessage.dependent_required(name, required_name) }
673
674
  end
674
675
  end
675
676
  end
@@ -707,7 +708,7 @@ module Schemurai
707
708
  return unless schema.key?(keyword)
708
709
  return if yield(actual, schema[keyword])
709
710
 
710
- add_error(keyword, "size limit was exceeded")
711
+ add_error(keyword) { ErrorMessage.size(keyword, schema[keyword], actual) }
711
712
  end
712
713
 
713
714
  private def type?(value, type)
@@ -803,10 +804,12 @@ module Schemurai
803
804
  JSON.parse(decoded)
804
805
  rescue ArgumentError, JSON::ParserError
805
806
  keyword = (schema["contentEncoding"] == "base64") ? "contentEncoding" : "contentMediaType"
806
- add_error(keyword, "string content is invalid")
807
+ add_error(keyword) do
808
+ (keyword == "contentEncoding") ? ErrorMessage.content_encoding : ErrorMessage.content_media_type
809
+ end
807
810
  end
808
811
 
809
- private def add_error(keyword, message, append_keyword: true)
812
+ private def add_error(keyword, message = nil, append_keyword: true)
810
813
  @error_count += 1
811
814
  if @error_callback
812
815
  schema_keyword = append_keyword ? keyword : MISSING_SEGMENT
@@ -815,7 +818,7 @@ module Schemurai
815
818
  keyword: keyword,
816
819
  instance_path: pointer(@instance_path),
817
820
  schema_path: pointer(@schema_path, schema_keyword),
818
- message: message
821
+ message: message || yield
819
822
  )
820
823
  )
821
824
  end
@@ -8,7 +8,7 @@ module Schemurai
8
8
  invalid!(label, "must be a boolean or an object")
9
9
  end
10
10
 
11
- validate_value!(schema, label, {})
11
+ validate_value!(schema, label, [], {})
12
12
  schema
13
13
  end
14
14
 
@@ -23,31 +23,37 @@ module Schemurai
23
23
  schemas
24
24
  end
25
25
 
26
- module_function def validate_value!(value, path, ancestors)
26
+ module_function def validate_value!(value, label, path, ancestors)
27
27
  case value
28
28
  when nil, true, false
29
29
  nil
30
30
  when String, Integer
31
- invalid!(path, "uses a subclass of #{value.class.superclass}") unless supported_exact_scalar?(value)
31
+ invalid_at!(label, path, "uses a subclass of #{value.class.superclass}") unless supported_exact_scalar?(value)
32
32
  when Float
33
- invalid!(path, "must contain only finite numbers") unless value.finite?
34
- invalid!(path, "uses a Float subclass") unless value.instance_of?(Float)
33
+ invalid_at!(label, path, "must contain only finite numbers") unless value.finite?
34
+ invalid_at!(label, path, "uses a Float subclass") unless value.instance_of?(Float)
35
35
  when Array
36
- invalid!(path, "uses an Array subclass") unless value.instance_of?(Array)
37
- descend!(value, path, ancestors) do
38
- value.each_with_index { |item, index| validate_value!(item, pointer(path, index), ancestors) }
36
+ invalid_at!(label, path, "uses an Array subclass") unless value.instance_of?(Array)
37
+ descend!(value, label, path, ancestors) do
38
+ value.each_with_index do |item, index|
39
+ path << index
40
+ validate_value!(item, label, path, ancestors)
41
+ path.pop
42
+ end
39
43
  end
40
44
  when Hash
41
- invalid!(path, "uses a Hash subclass") unless value.instance_of?(Hash)
42
- descend!(value, path, ancestors) do
45
+ invalid_at!(label, path, "uses a Hash subclass") unless value.instance_of?(Hash)
46
+ descend!(value, label, path, ancestors) do
43
47
  value.each do |key, item|
44
- invalid!(path, "contains a non-string object key") unless key.instance_of?(String)
48
+ invalid_at!(label, path, "contains a non-string object key") unless key.instance_of?(String)
45
49
 
46
- validate_value!(item, pointer(path, key), ancestors)
50
+ path << key
51
+ validate_value!(item, label, path, ancestors)
52
+ path.pop
47
53
  end
48
54
  end
49
55
  else
50
- invalid!(path, "contains unsupported #{value.class}")
56
+ invalid_at!(label, path, "contains unsupported #{value.class}")
51
57
  end
52
58
  end
53
59
  private_class_method :validate_value!
@@ -57,8 +63,8 @@ module Schemurai
57
63
  end
58
64
  private_class_method :supported_exact_scalar?
59
65
 
60
- module_function def descend!(value, path, ancestors)
61
- invalid!(path, "contains a recursive container") if ancestors.key?(value.object_id)
66
+ module_function def descend!(value, label, path, ancestors)
67
+ invalid_at!(label, path, "contains a recursive container") if ancestors.key?(value.object_id)
62
68
 
63
69
  ancestors[value.object_id] = true
64
70
  yield
@@ -67,11 +73,13 @@ module Schemurai
67
73
  end
68
74
  private_class_method :descend!
69
75
 
70
- module_function def pointer(path, segment)
71
- escaped = segment.to_s.gsub("~", "~0").gsub("/", "~1")
72
- "#{path}/#{escaped}"
76
+ module_function def invalid_at!(label, path, reason)
77
+ location = path.reduce(label.dup) do |result, segment|
78
+ result << "/" << segment.to_s.gsub("~", "~0").gsub("/", "~1")
79
+ end
80
+ invalid!(location, reason)
73
81
  end
74
- private_class_method :pointer
82
+ private_class_method :invalid_at!
75
83
 
76
84
  module_function def invalid!(path, reason)
77
85
  raise Error, "invalid JSON-shaped #{path}: #{reason}"
@@ -13,6 +13,44 @@ require_relative "schema_domain"
13
13
  module Schemurai
14
14
  module Internal
15
15
  class SchemaGraph
16
+ class CompilationChanges
17
+ EMPTY_HASH = {}.freeze
18
+ EMPTY_ARRAY = [].freeze
19
+
20
+ attr_reader :nodes
21
+ attr_accessor :requires_dynamic_scope
22
+
23
+ def initialize
24
+ @nodes = []
25
+ @requires_dynamic_scope = false
26
+ end
27
+
28
+ def resources = @resources || EMPTY_HASH
29
+ def mutable_resources = (@resources ||= {})
30
+
31
+ def resource_nodes = @resource_nodes || EMPTY_HASH
32
+ def mutable_resource_nodes = (@resource_nodes ||= {})
33
+
34
+ def uri_registry = @uri_registry || EMPTY_HASH
35
+ def mutable_uri_registry = (@uri_registry ||= {})
36
+
37
+ def dynamic_anchors = @dynamic_anchors || EMPTY_HASH
38
+ def mutable_dynamic_anchors = (@dynamic_anchors ||= {})
39
+
40
+ def custom_dialects = @custom_dialects || EMPTY_HASH
41
+ def mutable_custom_dialects = (@custom_dialects ||= {})
42
+
43
+ def document_locations
44
+ @document_locations || EMPTY_ARRAY
45
+ end
46
+
47
+ def mutable_document_locations
48
+ @document_locations ||= []
49
+ end
50
+ end
51
+
52
+ private_constant :CompilationChanges
53
+
16
54
  class Resource
17
55
  attr_reader :uri, :root
18
56
 
@@ -65,13 +103,15 @@ module Schemurai
65
103
 
66
104
  def compile(schema, base_uri: nil, dialect: @default_dialect)
67
105
  SchemaDomain.validate!(schema)
68
- roots = @compiled_roots.fetch(schema) { @compiled_roots[schema] = {} }
69
- cache_key = [base_uri.to_s, dialect]
70
- roots.fetch(cache_key) do
71
- roots[cache_key] = compile_atomically do |changes|
72
- root_dialect = dialect_for(schema, dialect, changes)
73
- compile_document(schema, base_uri.to_s, root_dialect, changes)
74
- end
106
+ roots = (@compiled_roots[schema] ||= {})
107
+ base_uri = base_uri.to_s
108
+ cache_key = [base_uri, dialect]
109
+ root = roots[cache_key]
110
+ return root if root
111
+
112
+ roots[cache_key] = compile_atomically do |changes|
113
+ root_dialect = dialect_for(schema, dialect, changes)
114
+ compile_document(schema, base_uri, root_dialect, changes)
75
115
  end
76
116
  end
77
117
 
@@ -132,16 +172,7 @@ module Schemurai
132
172
  end
133
173
 
134
174
  private def compilation_changes
135
- {
136
- resources: {},
137
- resource_nodes: {},
138
- uri_registry: {},
139
- nodes: [],
140
- document_locations: [],
141
- dynamic_anchors: {},
142
- custom_dialects: {},
143
- requires_dynamic_scope: false
144
- }
175
+ CompilationChanges.new
145
176
  end
146
177
 
147
178
  private def compile_atomically
@@ -152,22 +183,22 @@ module Schemurai
152
183
  end
153
184
 
154
185
  private def commit(changes)
155
- resources.update(changes[:resources])
156
- changes[:resource_nodes].each do |resource, resource_nodes|
186
+ resources.update(changes.resources)
187
+ changes.resource_nodes.each do |resource, resource_nodes|
157
188
  resource_nodes.each_value { |node| resource.add(node) }
158
189
  end
159
- uri_registry.update(changes[:uri_registry])
160
- nodes.concat(changes[:nodes])
190
+ uri_registry.update(changes.uri_registry)
191
+ nodes.concat(changes.nodes)
161
192
  if @nodes_by_document_location
162
- changes[:document_locations].each do |document_key, schema_path, node|
193
+ changes.document_locations.each do |document_key, schema_path, node|
163
194
  (@nodes_by_document_location[document_key] ||= {})[schema_path] = node
164
195
  end
165
196
  end
166
- changes[:dynamic_anchors].each do |resource, anchors|
197
+ changes.dynamic_anchors.each do |resource, anchors|
167
198
  (@dynamic_anchors[resource] ||= {}).update(anchors)
168
199
  end
169
- (@custom_dialects ||= {}).update(changes[:custom_dialects]) unless changes[:custom_dialects].empty?
170
- @dynamic_scope = true if changes[:requires_dynamic_scope]
200
+ (@custom_dialects ||= {}).update(changes.custom_dialects) unless changes.custom_dialects.empty?
201
+ @dynamic_scope = true if changes.requires_dynamic_scope
171
202
  end
172
203
 
173
204
  private def resolve_uncached(node, reference)
@@ -236,7 +267,7 @@ module Schemurai
236
267
  private def compile_node(schema, inherited_base, dialect, schema_path, resource_path, resource, document_key, changes)
237
268
  hash_schema = schema.is_a?(Hash)
238
269
  if hash_schema && (schema.key?("$recursiveRef") || schema.key?("$dynamicRef"))
239
- changes[:requires_dynamic_scope] = true
270
+ changes.requires_dynamic_scope = true
240
271
  end
241
272
  dialect = dialect_for(schema, dialect, changes) if hash_schema && schema.key?("$schema")
242
273
  if hash_schema && dialect.format_assertion? && schema.key?("format") &&
@@ -263,11 +294,13 @@ module Schemurai
263
294
  resource_path: resource_path,
264
295
  document_key: document_key
265
296
  )
266
- changes[:nodes] << node
267
- changes[:document_locations] << [document_key, schema_path, node]
297
+ changes.nodes << node
298
+ if @nodes_by_document_location
299
+ changes.mutable_document_locations << [document_key, schema_path, node]
300
+ end
268
301
 
269
302
  if hash_schema && schema.key?("$id") && !exclusive_ref && !base.empty?
270
- changes[:uri_registry][base] = node
303
+ changes.mutable_uri_registry[base] = node
271
304
  if starts_resource
272
305
  resource = register_resource(strip_fragment(base), node, changes)
273
306
  end
@@ -276,7 +309,7 @@ module Schemurai
276
309
  resource ||= register_resource(strip_fragment(base), node, changes)
277
310
  unless node.resource
278
311
  node.resource = resource
279
- (changes[:resource_nodes][resource] ||= {})[node.resource_path] = node unless node.equal?(resource.root)
312
+ (changes.mutable_resource_nodes[resource] ||= {})[node.resource_path] = node unless node.equal?(resource.root)
280
313
  end
281
314
 
282
315
  if hash_schema && !exclusive_ref
@@ -315,24 +348,24 @@ module Schemurai
315
348
  return resource if resource && resource.root.equal?(root)
316
349
  return resource if resource
317
350
 
318
- changes[:resources][uri] = Resource.new(uri, root)
351
+ changes.mutable_resources[uri] = Resource.new(uri, root)
319
352
  end
320
353
 
321
354
  private def resource_at(uri, changes)
322
- changes[:resources].fetch(uri) { resources[uri] }
355
+ changes.resources.fetch(uri) { resources[uri] }
323
356
  end
324
357
 
325
358
  private def register_uri_unless_present(uri, node, changes)
326
- return if changes[:uri_registry].key?(uri) || uri_registry.key?(uri)
359
+ return if changes.uri_registry.key?(uri) || uri_registry.key?(uri)
327
360
 
328
- changes[:uri_registry][uri] = node
361
+ changes.mutable_uri_registry[uri] = node
329
362
  end
330
363
 
331
364
  private def register_anchor(node, resource, name, changes, dynamic: false)
332
365
  return unless name.is_a?(String) && !name.empty?
333
366
 
334
- changes[:uri_registry]["#{resource.uri}##{name}"] = node
335
- ((changes[:dynamic_anchors][resource] ||= {})[name] = node) if dynamic
367
+ changes.mutable_uri_registry["#{resource.uri}##{name}"] = node
368
+ ((changes.mutable_dynamic_anchors[resource] ||= {})[name] = node) if dynamic
336
369
  end
337
370
 
338
371
  private def node_by_document_location(document_key, schema_path)
@@ -392,7 +425,7 @@ module Schemurai
392
425
  meta_schema = @external_schemas[uri] || @external_schemas[uri.delete_suffix("#")]
393
426
  return unless meta_schema.is_a?(Hash) && meta_schema["$vocabulary"].is_a?(Hash)
394
427
 
395
- custom_dialects = changes ? changes[:custom_dialects] : (@custom_dialects ||= {})
428
+ custom_dialects = changes ? changes.mutable_custom_dialects : (@custom_dialects ||= {})
396
429
  return custom_dialects[uri] if custom_dialects.key?(uri)
397
430
  return @custom_dialects[uri] if @custom_dialects&.key?(uri)
398
431
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schemurai
4
4
  # The installed Schemurai version.
5
- VERSION = "2.0.0"
5
+ VERSION = "2.2.0"
6
6
  end