jsi 0.6.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -1
  3. data/CHANGELOG.md +33 -0
  4. data/LICENSE.md +1 -1
  5. data/README.md +29 -23
  6. data/jsi.gemspec +29 -0
  7. data/lib/jsi/base/mutability.rb +44 -0
  8. data/lib/jsi/base/node.rb +348 -0
  9. data/lib/jsi/base.rb +497 -339
  10. data/lib/jsi/jsi_coder.rb +19 -17
  11. data/lib/jsi/metaschema_node/bootstrap_schema.rb +61 -26
  12. data/lib/jsi/metaschema_node.rb +161 -133
  13. data/lib/jsi/ptr.rb +80 -47
  14. data/lib/jsi/schema/application/child_application/contains.rb +11 -2
  15. data/lib/jsi/schema/application/child_application/draft04.rb +0 -1
  16. data/lib/jsi/schema/application/child_application/draft06.rb +0 -1
  17. data/lib/jsi/schema/application/child_application/draft07.rb +0 -1
  18. data/lib/jsi/schema/application/child_application/items.rb +3 -3
  19. data/lib/jsi/schema/application/child_application/properties.rb +3 -3
  20. data/lib/jsi/schema/application/child_application.rb +0 -27
  21. data/lib/jsi/schema/application/inplace_application/dependencies.rb +1 -1
  22. data/lib/jsi/schema/application/inplace_application/draft04.rb +0 -1
  23. data/lib/jsi/schema/application/inplace_application/draft06.rb +0 -1
  24. data/lib/jsi/schema/application/inplace_application/draft07.rb +0 -1
  25. data/lib/jsi/schema/application/inplace_application/ifthenelse.rb +3 -3
  26. data/lib/jsi/schema/application/inplace_application/ref.rb +2 -2
  27. data/lib/jsi/schema/application/inplace_application/someof.rb +26 -11
  28. data/lib/jsi/schema/application/inplace_application.rb +0 -32
  29. data/lib/jsi/schema/draft04.rb +0 -1
  30. data/lib/jsi/schema/draft06.rb +0 -1
  31. data/lib/jsi/schema/draft07.rb +0 -1
  32. data/lib/jsi/schema/ref.rb +46 -19
  33. data/lib/jsi/schema/schema_ancestor_node.rb +69 -66
  34. data/lib/jsi/schema/validation/array.rb +3 -3
  35. data/lib/jsi/schema/validation/const.rb +1 -1
  36. data/lib/jsi/schema/validation/contains.rb +2 -2
  37. data/lib/jsi/schema/validation/dependencies.rb +1 -1
  38. data/lib/jsi/schema/validation/draft04/minmax.rb +8 -6
  39. data/lib/jsi/schema/validation/draft04.rb +0 -2
  40. data/lib/jsi/schema/validation/draft06.rb +0 -2
  41. data/lib/jsi/schema/validation/draft07.rb +0 -2
  42. data/lib/jsi/schema/validation/enum.rb +1 -1
  43. data/lib/jsi/schema/validation/ifthenelse.rb +5 -5
  44. data/lib/jsi/schema/validation/items.rb +7 -7
  45. data/lib/jsi/schema/validation/not.rb +1 -1
  46. data/lib/jsi/schema/validation/numeric.rb +5 -5
  47. data/lib/jsi/schema/validation/object.rb +2 -2
  48. data/lib/jsi/schema/validation/pattern.rb +2 -2
  49. data/lib/jsi/schema/validation/properties.rb +7 -7
  50. data/lib/jsi/schema/validation/property_names.rb +1 -1
  51. data/lib/jsi/schema/validation/ref.rb +2 -2
  52. data/lib/jsi/schema/validation/required.rb +1 -1
  53. data/lib/jsi/schema/validation/someof.rb +3 -3
  54. data/lib/jsi/schema/validation/string.rb +2 -2
  55. data/lib/jsi/schema/validation/type.rb +1 -1
  56. data/lib/jsi/schema/validation.rb +1 -3
  57. data/lib/jsi/schema.rb +443 -226
  58. data/lib/jsi/schema_classes.rb +241 -147
  59. data/lib/jsi/schema_registry.rb +78 -19
  60. data/lib/jsi/schema_set.rb +114 -28
  61. data/lib/jsi/simple_wrap.rb +18 -4
  62. data/lib/jsi/util/private/attr_struct.rb +141 -0
  63. data/lib/jsi/util/private/memo_map.rb +75 -0
  64. data/lib/jsi/util/private.rb +185 -0
  65. data/lib/jsi/{typelike_modules.rb → util/typelike.rb} +79 -105
  66. data/lib/jsi/util.rb +157 -153
  67. data/lib/jsi/validation/error.rb +4 -0
  68. data/lib/jsi/validation/result.rb +18 -32
  69. data/lib/jsi/version.rb +1 -1
  70. data/lib/jsi.rb +65 -39
  71. data/lib/schemas/json-schema.org/draft-04/schema.rb +160 -3
  72. data/lib/schemas/json-schema.org/draft-06/schema.rb +162 -3
  73. data/lib/schemas/json-schema.org/draft-07/schema.rb +189 -3
  74. metadata +27 -11
  75. data/lib/jsi/metaschema.rb +0 -7
  76. data/lib/jsi/pathed_node.rb +0 -116
  77. data/lib/jsi/schema/validation/core.rb +0 -39
  78. data/lib/jsi/util/attr_struct.rb +0 -106
@@ -14,6 +14,7 @@ module JSI
14
14
 
15
15
  # an exception raised when a URI we are looking for has not been registered
16
16
  class ResourceNotFound < StandardError
17
+ attr_accessor :uri
17
18
  end
18
19
 
19
20
  def initialize
@@ -24,8 +25,8 @@ module JSI
24
25
 
25
26
  # registers the given resource and/or schema resources it contains in the registry.
26
27
  #
27
- # each child of the resource (including the resource itself) is registered if it is a schema that
28
- # has an absolute URI (generally defined by the '$id' keyword).
28
+ # each descendent node of the resource (including the resource itself) is registered if it is a schema
29
+ # that has an absolute URI (generally defined by the '$id' keyword).
29
30
  #
30
31
  # the given resource itself will be registered, whether or not it is a schema, if it is the root
31
32
  # of its document and was instantiated with the option `uri` specified.
@@ -45,12 +46,12 @@ module JSI
45
46
  # allow for registration of resources at the root of a document whether or not they are schemas.
46
47
  # jsi_schema_base_uri at the root comes from the `uri` parameter to new_jsi / new_schema.
47
48
  if resource.jsi_schema_base_uri && resource.jsi_ptr.root?
48
- register_single(resource.jsi_schema_base_uri, resource)
49
+ internal_store(resource.jsi_schema_base_uri, resource)
49
50
  end
50
51
 
51
- resource.jsi_each_child_node do |node|
52
+ resource.jsi_each_descendent_node do |node|
52
53
  if node.is_a?(JSI::Schema) && node.schema_absolute_uri
53
- register_single(node.schema_absolute_uri, node)
54
+ internal_store(node.schema_absolute_uri, node)
54
55
  end
55
56
  end
56
57
 
@@ -72,12 +73,18 @@ module JSI
72
73
  #
73
74
  # the block would normally load JSON from the filesystem or similar.
74
75
  #
75
- # @param uri [Addressable::URI]
76
+ # @param uri [#to_str]
76
77
  # @yieldreturn [JSI::Base] a JSI instance containing the resource identified by the given uri
77
78
  # @return [void]
78
79
  def autoload_uri(uri, &block)
79
- uri = Addressable::URI.parse(uri)
80
- ensure_uri_absolute(uri)
80
+ uri = registration_uri(uri)
81
+ mutating
82
+ unless block
83
+ raise(ArgumentError, ["#{SchemaRegistry}#autoload_uri must be invoked with a block", "URI: #{uri}"].join("\n"))
84
+ end
85
+ if @autoload_uris.key?(uri)
86
+ raise(Collision, ["already registered URI for autoload", "URI: #{uri}", "loader: #{@autoload_uris[uri]}"].join("\n"))
87
+ end
81
88
  @autoload_uris[uri] = block
82
89
  nil
83
90
  end
@@ -86,22 +93,50 @@ module JSI
86
93
  # @return [JSI::Base]
87
94
  # @raise [JSI::SchemaRegistry::ResourceNotFound]
88
95
  def find(uri)
89
- uri = Addressable::URI.parse(uri)
90
- ensure_uri_absolute(uri)
91
- if @autoload_uris.key?(uri) && !@resources.key?(uri)
92
- register(@autoload_uris[uri].call)
96
+ uri = registration_uri(uri)
97
+ if @autoload_uris.key?(uri)
98
+ autoloaded = @autoload_uris[uri].call
99
+ register(autoloaded)
100
+ @autoload_uris.delete(uri)
93
101
  end
94
- registered_uris = @resources.keys
95
- if !registered_uris.include?(uri)
96
- raise(ResourceNotFound, "URI #{uri} is not registered. registered URIs:\n#{registered_uris.join("\n")}")
102
+ if !@resources.key?(uri)
103
+ if autoloaded
104
+ msg = [
105
+ "URI #{uri} was registered with autoload_uri but the result did not contain a resource with that URI.",
106
+ "the resource resulting from autoload_uri was:",
107
+ autoloaded.pretty_inspect.chomp,
108
+ ]
109
+ else
110
+ msg = ["URI #{uri} is not registered. registered URIs:", *(@resources.keys | @autoload_uris.keys)]
111
+ end
112
+ raise(ResourceNotFound.new(msg.join("\n")).tap { |e| e.uri = uri })
97
113
  end
98
114
  @resources[uri]
99
115
  end
100
116
 
117
+ def inspect
118
+ [
119
+ "#<#{self.class}",
120
+ *[['resources', @resources.keys], ['autoload', @autoload_uris.keys]].map do |label, uris|
121
+ [
122
+ " #{label} (#{uris.size})#{uris.empty? ? "" : ":"}",
123
+ *uris.map do |uri|
124
+ " #{uri}"
125
+ end,
126
+ ]
127
+ end.inject([], &:+),
128
+ '>',
129
+ ].join("\n").freeze
130
+ end
131
+
132
+ def to_s
133
+ inspect
134
+ end
135
+
101
136
  def dup
102
137
  self.class.new.tap do |reg|
103
138
  @resources.each do |uri, resource|
104
- reg.register_single(uri, resource)
139
+ reg.internal_store(uri, resource)
105
140
  end
106
141
  @autoload_uris.each do |uri, autoload|
107
142
  reg.autoload_uri(uri, &autoload)
@@ -109,13 +144,21 @@ module JSI
109
144
  end
110
145
  end
111
146
 
147
+ def freeze
148
+ @resources.freeze
149
+ @autoload_uris.freeze
150
+ @resources_mutex = nil
151
+ super
152
+ end
153
+
112
154
  protected
113
155
  # @param uri [Addressable::URI]
114
156
  # @param resource [JSI::Base]
115
157
  # @return [void]
116
- def register_single(uri, resource)
158
+ def internal_store(uri, resource)
159
+ mutating
117
160
  @resources_mutex.synchronize do
118
- ensure_uri_absolute(uri)
161
+ uri = registration_uri(uri)
119
162
  if @resources.key?(uri)
120
163
  if @resources[uri] != resource
121
164
  raise(Collision, "URI collision on #{uri}.\nexisting:\n#{@resources[uri].pretty_inspect.chomp}\nnew:\n#{resource.pretty_inspect.chomp}")
@@ -129,13 +172,29 @@ module JSI
129
172
 
130
173
  private
131
174
 
132
- def ensure_uri_absolute(uri)
175
+ # registration URIs are
176
+ # - absolute
177
+ # - without fragment
178
+ # - not relative
179
+ # - normalized
180
+ # - frozen
181
+ # @param uri [#to_str]
182
+ # @return [Addressable::URI]
183
+ def registration_uri(uri)
184
+ uri = Util.uri(uri)
133
185
  if uri.fragment
134
186
  raise(NonAbsoluteURI, "#{self.class} only registers absolute URIs. cannot access URI with fragment: #{uri}")
135
187
  end
136
188
  if uri.relative?
137
189
  raise(NonAbsoluteURI, "#{self.class} only registers absolute URIs. cannot access relative URI: #{uri}")
138
190
  end
191
+ uri.normalize.freeze
192
+ end
193
+
194
+ def mutating
195
+ if frozen?
196
+ raise(FrozenError, "cannot modify frozen #{self.class}")
197
+ end
139
198
  end
140
199
  end
141
200
  end
@@ -6,14 +6,12 @@ module JSI
6
6
  # any schema instance is described by a set of schemas.
7
7
  class SchemaSet < ::Set
8
8
  class << self
9
- # builds a SchemaSet from a mutable Set which is added to by the given block
9
+ # Builds a SchemaSet, yielding a yielder to be called with each schema of the SchemaSet.
10
10
  #
11
- # @yield [Set] a Set to which the block may add schemas
11
+ # @yield [Enumerator::Yielder]
12
12
  # @return [SchemaSet]
13
- def build
14
- mutable_set = Set.new
15
- yield mutable_set
16
- new(mutable_set)
13
+ def build(&block)
14
+ new(Enumerator.new(&block))
17
15
  end
18
16
 
19
17
  # ensures the given param becomes a SchemaSet. returns the param if it is already SchemaSet, otherwise
@@ -42,12 +40,25 @@ module JSI
42
40
  # @yieldreturn [JSI::Schema]
43
41
  # @raise [JSI::Schema::NotASchemaError]
44
42
  def initialize(enum, &block)
43
+ if enum.is_a?(Schema)
44
+ raise(ArgumentError, [
45
+ "#{SchemaSet} initialized with a #{Schema}",
46
+ "you probably meant to pass that to #{SchemaSet}[]",
47
+ "or to wrap that schema in a Set or Array for #{SchemaSet}.new",
48
+ "given: #{enum.pretty_inspect.chomp}",
49
+ ].join("\n"))
50
+ end
51
+
52
+ unless enum.is_a?(Enumerable)
53
+ raise(ArgumentError, "#{SchemaSet} initialized with non-Enumerable: #{enum.pretty_inspect.chomp}")
54
+ end
55
+
45
56
  super
46
57
 
47
58
  not_schemas = reject { |s| s.is_a?(Schema) }
48
59
  if !not_schemas.empty?
49
60
  raise(Schema::NotASchemaError, [
50
- "JSI::SchemaSet initialized with non-schema objects:",
61
+ "#{SchemaSet} initialized with non-schema objects:",
51
62
  *not_schemas.map { |ns| ns.pretty_inspect.chomp },
52
63
  ].join("\n"))
53
64
  end
@@ -55,33 +66,79 @@ module JSI
55
66
  freeze
56
67
  end
57
68
 
58
- # instantiates the given instance as a JSI. its schemas are inplace applicators matched from the schemas
59
- # in this SchemaSet which apply to the given instance.
69
+ # Instantiates a new JSI whose content comes from the given `instance` param.
70
+ # This SchemaSet indicates the schemas of the JSI - its schemas are inplace
71
+ # applicators of this set's schemas which apply to the given instance.
72
+ #
73
+ # @param instance [Object] the instance to be represented as a JSI
74
+ # @param uri [#to_str, Addressable::URI] The retrieval URI of the instance.
60
75
  #
61
- # @param instance [Object] the JSON Schema instance to be represented as a JSI
62
- # @param uri [nil, #to_str, Addressable::URI] for an instance document containing schemas, this is
63
- # the URI of the document, whether or not the document is itself a schema.
64
- # in the normal case where the document does not contain any schemas, `uri` has no effect.
65
- # schemas within the document use this uri as the base URI to resolve relative URIs.
66
- # the resulting JSI may be registered with a {SchemaRegistry} (see {JSI.schema_registry}).
67
- # @return [JSI::Base subclass] a JSI whose instance is the given instance and whose schemas are inplace
68
- # applicators matched to the instance from the schemas in this set.
76
+ # It is rare that this needs to be specified, and only useful for instances which contain schemas.
77
+ # See {Schema::MetaSchema#new_schema}'s `uri` param documentation.
78
+ # @param register [Boolean] Whether schema resources in the instantiated JSI will be registered
79
+ # in the schema registry indicated by param `schema_registry`.
80
+ # This is only useful when the JSI is a schema or contains schemas.
81
+ # The JSI's root will be registered with the `uri` param, if specified, whether or not the
82
+ # root is a schema.
83
+ # @param schema_registry [SchemaRegistry, nil] The registry to use for references to other schemas and,
84
+ # depending on `register` and `uri` params, to register this JSI and/or any contained schemas with
85
+ # declared URIs.
86
+ # @param stringify_symbol_keys [Boolean] Whether the instance content will have any Symbol keys of Hashes
87
+ # replaced with Strings (recursively through the document).
88
+ # Replacement is done on a copy; the given instance is not modified.
89
+ # @param to_immutable [#call, nil] A proc/callable which takes given instance content
90
+ # and results in an immutable (i.e. deeply frozen) object equal to that.
91
+ # If the instantiated JSI will be mutable, this is not used.
92
+ # Though not recommended, this may be nil with immutable JSIs if the instance content is otherwise
93
+ # guaranteed to be immutable, as well as any modified copies of the instance.
94
+ # @param mutable [Boolean] Whether the instantiated JSI will be mutable.
95
+ # The instance content will be transformed with `to_immutable` if the JSI will be immutable.
96
+ # @return [JSI::Base subclass] a JSI whose content comes from the given instance and whose schemas are
97
+ # inplace applicators of the schemas in this set.
69
98
  def new_jsi(instance,
70
- uri: nil
99
+ uri: nil,
100
+ register: false,
101
+ schema_registry: JSI.schema_registry,
102
+ stringify_symbol_keys: false,
103
+ to_immutable: DEFAULT_CONTENT_TO_IMMUTABLE,
104
+ mutable: true
71
105
  )
106
+ instance = Util.deep_stringify_symbol_keys(instance) if stringify_symbol_keys
107
+
108
+ instance = to_immutable.call(instance) if !mutable && to_immutable
109
+
72
110
  applied_schemas = inplace_applicator_schemas(instance)
73
111
 
74
- jsi = JSI::SchemaClasses.class_for_schemas(applied_schemas).new(instance,
112
+ if uri
113
+ unless uri.respond_to?(:to_str)
114
+ raise(TypeError, "uri must be string or Addressable::URI; got: #{uri.inspect}")
115
+ end
116
+ uri = Util.uri(uri)
117
+ unless uri.absolute? && !uri.fragment
118
+ raise(ArgumentError, "uri must be an absolute URI with no fragment; got: #{uri.inspect}")
119
+ end
120
+ end
121
+
122
+ jsi_class = JSI::SchemaClasses.class_for_schemas(applied_schemas,
123
+ includes: SchemaClasses.includes_for(instance),
124
+ mutable: mutable,
125
+ )
126
+ jsi = jsi_class.new(instance,
127
+ jsi_indicated_schemas: self,
75
128
  jsi_schema_base_uri: uri,
129
+ jsi_schema_registry: schema_registry,
130
+ jsi_content_to_immutable: to_immutable,
76
131
  )
77
132
 
133
+ schema_registry.register(jsi) if register && schema_registry
134
+
78
135
  jsi
79
136
  end
80
137
 
81
138
  # a set of inplace applicator schemas of each schema in this set which apply to the given instance.
82
- # (see {Schema::Application::InplaceApplication#inplace_applicator_schemas})
139
+ # (see {Schema#inplace_applicator_schemas})
83
140
  #
84
- # @param instance (see Schema::Application::InplaceApplication#inplace_applicator_schemas)
141
+ # @param instance (see Schema#inplace_applicator_schemas)
85
142
  # @return [JSI::SchemaSet]
86
143
  def inplace_applicator_schemas(instance)
87
144
  SchemaSet.new(each_inplace_applicator_schema(instance))
@@ -89,7 +146,7 @@ module JSI
89
146
 
90
147
  # yields each inplace applicator schema which applies to the given instance.
91
148
  #
92
- # @param instance (see Schema::Application::InplaceApplication#inplace_applicator_schemas)
149
+ # @param instance (see Schema#inplace_applicator_schemas)
93
150
  # @yield [JSI::Schema]
94
151
  # @return [nil, Enumerator] an Enumerator if invoked without a block; otherwise nil
95
152
  def each_inplace_applicator_schema(instance, &block)
@@ -102,13 +159,40 @@ module JSI
102
159
  nil
103
160
  end
104
161
 
162
+ # a set of child applicator subschemas of each schema in this set which apply to the child
163
+ # of the given instance on the given token.
164
+ # (see {Schema#child_applicator_schemas})
165
+ #
166
+ # @param instance (see Schema#child_applicator_schemas)
167
+ # @return [JSI::SchemaSet]
168
+ def child_applicator_schemas(token, instance)
169
+ SchemaSet.new(each_child_applicator_schema(token, instance))
170
+ end
171
+
172
+ # yields each child applicator schema which applies to the child of
173
+ # the given instance on the given token.
174
+ #
175
+ # @param (see Schema#child_applicator_schemas)
176
+ # @yield [JSI::Schema]
177
+ # @return [nil, Enumerator] an Enumerator if invoked without a block; otherwise nil
178
+ def each_child_applicator_schema(token, instance, &block)
179
+ return to_enum(__method__, token, instance) unless block
180
+
181
+ each do |schema|
182
+ schema.each_child_applicator_schema(token, instance, &block)
183
+ end
184
+
185
+ nil
186
+ end
187
+
105
188
  # validates the given instance against our schemas
106
189
  #
107
190
  # @param instance [Object] the instance to validate against our schemas
108
191
  # @return [JSI::Validation::Result]
109
192
  def instance_validate(instance)
110
- results = map { |schema| schema.instance_validate(instance) }
111
- results.inject(Validation::FullResult.new, &:merge).freeze
193
+ inject(Validation::FullResult.new) do |result, schema|
194
+ result.merge(schema.instance_validate(instance))
195
+ end.freeze
112
196
  end
113
197
 
114
198
  # whether the given instance is valid against our schemas
@@ -120,19 +204,21 @@ module JSI
120
204
 
121
205
  # @return [String]
122
206
  def inspect
123
- "#{self.class}[#{map(&:inspect).join(", ")}]"
207
+ -"#{self.class}[#{map(&:inspect).join(", ")}]"
208
+ end
209
+
210
+ def to_s
211
+ inspect
124
212
  end
125
213
 
126
214
  def pretty_print(q)
127
215
  q.text self.class.to_s
128
216
  q.text '['
129
- q.group_sub {
130
- q.nest(2) {
217
+ q.group(2) {
131
218
  q.breakable('')
132
219
  q.seplist(self, nil, :each) { |e|
133
220
  q.pp e
134
221
  }
135
- }
136
222
  }
137
223
  q.breakable ''
138
224
  q.text ']'
@@ -1,12 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSI
4
- SimpleWrap = JSI::JSONSchemaOrgDraft06.new_schema_module({
5
- "additionalProperties": {"$ref": "#"},
6
- "items": {"$ref": "#"}
7
- })
4
+ simple_wrap_implementation = Module.new do
5
+ def internal_child_applicate_keywords(token, instance)
6
+ yield self
7
+ end
8
+
9
+ def internal_inplace_applicate_keywords(instance, visited_refs)
10
+ yield self
11
+ end
12
+
13
+ def internal_validate_keywords(result_builder)
14
+ end
15
+ end
16
+
17
+ simple_wrap_metaschema = JSI.new_metaschema(nil, schema_implementation_modules: [simple_wrap_implementation])
18
+ SimpleWrap = simple_wrap_metaschema.new_schema_module(Util::EMPTY_HASH)
8
19
 
9
20
  # SimpleWrap is a JSI schema module which recursively wraps nested structures
10
21
  module SimpleWrap
11
22
  end
23
+
24
+ SimpleWrap::Implementation = simple_wrap_implementation
25
+ SimpleWrap::METASCHEMA = simple_wrap_metaschema
12
26
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSI
4
+ module Util::Private
5
+ # like a Struct, but stores all the attributes in one @attributes Hash, instead of individual instance
6
+ # variables for each attribute.
7
+ # this tends to be easier to work with and more flexible. keys which are symbols are converted to strings.
8
+ class AttrStruct
9
+ class AttrStructError < StandardError
10
+ end
11
+
12
+ class UndefinedAttributeKey < AttrStructError
13
+ end
14
+
15
+ class << self
16
+ # creates a AttrStruct subclass with the given attribute keys.
17
+ # @param attribute_keys [Enumerable<String, Symbol>]
18
+ def subclass(*attribute_keys)
19
+ bad_type = attribute_keys.reject { |key| key.respond_to?(:to_str) || key.is_a?(Symbol) }
20
+ unless bad_type.empty?
21
+ raise(ArgumentError, "attribute keys must be String or Symbol; got keys: #{bad_type.map(&:inspect).join(', ')}")
22
+ end
23
+
24
+ attribute_keys = attribute_keys.map { |key| convert_key(key) }
25
+
26
+ bad_name = attribute_keys.reject { |key| Util::Private.ok_ruby_method_name?(key) }
27
+ unless bad_name.empty?
28
+ raise(ArgumentError, "attribute keys must be valid ruby method names; got keys: #{bad_name.map(&:inspect).join(', ')}")
29
+ end
30
+
31
+ all_attribute_keys = (self.attribute_keys + attribute_keys).freeze
32
+
33
+ Class.new(self).tap do |klass|
34
+ klass.define_singleton_method(:attribute_keys) { all_attribute_keys }
35
+
36
+ attribute_keys.each do |attribute_key|
37
+ # reader
38
+ klass.send(:define_method, attribute_key) do
39
+ @attributes[attribute_key]
40
+ end
41
+
42
+ # writer
43
+ klass.send(:define_method, "#{attribute_key}=") do |value|
44
+ @attributes[attribute_key] = value
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ alias_method :[], :subclass
51
+
52
+ # the attribute keys defined for this class
53
+ # @return [Set<String>]
54
+ def attribute_keys
55
+ # empty for AttrStruct itself; redefined on each subclass
56
+ Util::Private::EMPTY_SET
57
+ end
58
+
59
+ # returns a frozen string, given a string or symbol.
60
+ # returns anything else as-is for the caller to handle.
61
+ # @api private
62
+ def convert_key(key)
63
+ # TODO use Symbol#name when available on supported rubies
64
+ key.is_a?(Symbol) ? key.to_s.freeze : key.frozen? ? key : key.is_a?(String) ? key.dup.freeze : key
65
+ end
66
+ end
67
+
68
+ def initialize(attributes = {})
69
+ unless attributes.respond_to?(:to_hash)
70
+ raise(TypeError, "expected attributes to be a Hash; got: #{attributes.inspect}")
71
+ end
72
+ @attributes = {}
73
+ attributes.to_hash.each do |k, v|
74
+ @attributes[self.class.convert_key(k)] = v
75
+ end
76
+ bad = @attributes.keys.reject { |k| class_attribute_keys.include?(k) }
77
+ unless bad.empty?
78
+ raise UndefinedAttributeKey, "undefined attribute keys: #{bad.map(&:inspect).join(', ')}"
79
+ end
80
+ end
81
+
82
+ def [](key)
83
+ @attributes[key.is_a?(Symbol) ? key.to_s : key]
84
+ end
85
+
86
+ def []=(key, value)
87
+ key = self.class.convert_key(key)
88
+ unless class_attribute_keys.include?(key)
89
+ raise UndefinedAttributeKey, "undefined attribute key: #{key.inspect}"
90
+ end
91
+ @attributes[key] = value
92
+ end
93
+
94
+ # @return [String]
95
+ def inspect
96
+ -"\#<#{self.class.name}#{@attributes.map { |k, v| " #{k}: #{v.inspect}" }.join(',')}>"
97
+ end
98
+
99
+ def to_s
100
+ inspect
101
+ end
102
+
103
+ # pretty-prints a representation of self to the given printer
104
+ # @return [void]
105
+ def pretty_print(q)
106
+ q.text '#<'
107
+ q.text self.class.name
108
+ q.group(2) {
109
+ q.breakable(' ') if !@attributes.empty?
110
+ q.seplist(@attributes, nil, :each_pair) { |k, v|
111
+ q.group {
112
+ q.text k
113
+ q.text ': '
114
+ q.pp v
115
+ }
116
+ }
117
+ }
118
+ q.breakable('') if !@attributes.empty?
119
+ q.text '>'
120
+ end
121
+
122
+ # (see AttrStruct.attribute_keys)
123
+ def class_attribute_keys
124
+ self.class.attribute_keys
125
+ end
126
+
127
+ def freeze
128
+ @attributes.freeze
129
+ super
130
+ end
131
+
132
+ include FingerprintHash
133
+
134
+ # see {Util::Private::FingerprintHash}
135
+ # @api private
136
+ def jsi_fingerprint
137
+ {class: self.class, attributes: @attributes}.freeze
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSI
4
+ module Util::Private
5
+ class MemoMap
6
+ def initialize(key_by: nil, &block)
7
+ @key_by = key_by
8
+ @block = block || raise(ArgumentError, "no block given")
9
+
10
+ # each result has its own mutex to update its memoized value thread-safely
11
+ @result_mutexes = {}
12
+ # another mutex to thread-safely initialize each result mutex
13
+ @result_mutexes_mutex = Mutex.new
14
+
15
+ @results = {}
16
+ end
17
+
18
+ def key_for(inputs)
19
+ if @key_by
20
+ @key_by.call(**inputs)
21
+ else
22
+ inputs
23
+ end
24
+ end
25
+ end
26
+
27
+ class MemoMap::Mutable < MemoMap
28
+ Result = AttrStruct[*%w(
29
+ value
30
+ inputs
31
+ inputs_hash
32
+ )]
33
+
34
+ class Result
35
+ end
36
+
37
+ def [](**inputs)
38
+ key = key_for(inputs)
39
+
40
+ result_mutex = @result_mutexes_mutex.synchronize do
41
+ @result_mutexes[key] ||= Mutex.new
42
+ end
43
+
44
+ result_mutex.synchronize do
45
+ inputs_hash = inputs.hash
46
+ if @results.key?(key) && inputs_hash == @results[key].inputs_hash && inputs == @results[key].inputs
47
+ @results[key].value
48
+ else
49
+ value = @block.call(**inputs)
50
+ @results[key] = Result.new(value: value, inputs: inputs, inputs_hash: inputs_hash)
51
+ value
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ class MemoMap::Immutable < MemoMap
58
+ def [](**inputs)
59
+ key = key_for(inputs)
60
+
61
+ result_mutex = @result_mutexes_mutex.synchronize do
62
+ @result_mutexes[key] ||= Mutex.new
63
+ end
64
+
65
+ result_mutex.synchronize do
66
+ if @results.key?(key)
67
+ @results[key]
68
+ else
69
+ @results[key] = @block.call(**inputs)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end