jsi 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  [![Build Status](https://travis-ci.org/notEthan/jsi.svg?branch=master)](https://travis-ci.org/notEthan/jsi)
4
4
  [![Coverage Status](https://coveralls.io/repos/github/notEthan/jsi/badge.svg)](https://coveralls.io/github/notEthan/jsi)
5
5
 
6
- JSI offers an Object-Oriented representation for JSON data using JSON Schemas. Given your JSON Schemas, JSI constructs Ruby classes which are used to instantiate your JSON data. These classes let you use JSON with all the niceties of OOP such as property accessors and application-defined instance methods.
6
+ JSI offers an Object-Oriented representation for JSON data using JSON Schemas. Given your JSON Schemas, JSI constructs Ruby modules and classes which are used to instantiate your JSON data. These modules let you use JSON with all the niceties of OOP such as property accessors and application-defined instance methods.
7
7
 
8
8
  To learn more about JSON Schema see [https://json-schema.org/]().
9
9
 
10
- A JSI class aims to be a fairly unobtrusive wrapper around its instance - "instance" here meaning the JSON data which instantiate the JSON Schema. The instance is usually a Hash or an Array but may be basic types, or in fact any object. A JSI class adds accessors for property names described by its schema, schema validation, and other nice things. Mostly though, you use a JSI as you would use its underlying data, calling the same methods (e.g. `#[]`, `#map`, `#repeated_permutation`) and passing it to anything that duck-types expecting #to_ary or #to_hash.
10
+ A JSI class aims to be a fairly unobtrusive wrapper around its instance - "instance" here meaning the JSON data, usually a Hash or Array, which instantiate the JSON Schema. JSI schema modules and classes add accessors for property names described by its schema, schema validation, and other nice things. Mostly though, you use a JSI as you would use its underlying data, calling the same methods (e.g. `#[]`, `#map`, `#repeated_permutation`) and passing it to anything that duck-types expecting `#to_ary` or `#to_hash`.
11
11
 
12
12
  ## Example
13
13
 
@@ -21,20 +21,27 @@ properties:
21
21
  phone:
22
22
  type: "array"
23
23
  items:
24
+ description: "A phone number"
24
25
  type: "object"
25
26
  properties:
26
27
  location: {type: "string"}
27
28
  number: {type: "string"}
28
29
  ```
29
30
 
30
- And here's how you'd normally instantiate the class for that schema using JSI:
31
+ Using that schema, we instantiate a JSI::Schema to represent it:
31
32
 
32
33
  ```ruby
33
34
  # this would usually use a YAML.load/JSON.parse/whatever; it's inlined for copypastability.
34
- Contact = JSI.class_for_schema({"description" => "A Contact", "type" => "object", "properties" => {"name" => {"type" => "string"}, "phone" => {"type" => "array", "items" => {"type" => "object", "properties" => {"location" => {"type" => "string"}, "number" => {"type" => "string"}}}}}})
35
+ contact_schema = JSI::Schema.new({"description" => "A Contact", "type" => "object", "properties" => {"name" => {"type" => "string"}, "phone" => {"type" => "array", "items" => {"type" => "object", "properties" => {"location" => {"type" => "string"}, "number" => {"type" => "string"}}}}}})
35
36
  ```
36
37
 
37
- This definition gives you not just the Contact class, but classes for the whole nested structure. To instantiate it, we need some JSON data (expressed here as YAML)
38
+ We name the module that JSI will use when instantiating a contact. Named modules are better to work with, and JSI will indicate the names of schema modules in its `#inspect` output.
39
+
40
+ ```ruby
41
+ Contact = contact_schema.jsi_schema_module
42
+ ```
43
+
44
+ To instantiate the schema, we need some JSON data (expressed here as YAML)
38
45
 
39
46
  ```yaml
40
47
  name: bill
@@ -48,11 +55,14 @@ So, if we construct an instance like:
48
55
 
49
56
  ```ruby
50
57
  # this would usually use a YAML.load/JSON.parse/whatever; it's inlined for copypastability.
51
- bill = Contact.new({"name" => "bill", "phone" => [{"location" => "home", "number" => "555"}], "nickname" => "big b"})
52
- # => #{<Contact Hash>
58
+ bill = Contact.new_jsi({"name" => "bill", "phone" => [{"location" => "home", "number" => "555"}], "nickname" => "big b"})
59
+ # => #{<JSI (Contact)>
53
60
  # "name" => "bill",
54
- # "phone" => #[<JSI::SchemaClasses["23d8#/properties/phone"] Array>
55
- # #{<JSI::SchemaClasses["23d8#/properties/phone/items"] Hash> "location" => "home", "number" => "555"}
61
+ # "phone" => #[<JSI>
62
+ # #{<JSI>
63
+ # "location" => "home",
64
+ # "number" => "555"
65
+ # }
56
66
  # ],
57
67
  # "nickname" => "big b"
58
68
  # }
@@ -60,8 +70,6 @@ bill = Contact.new({"name" => "bill", "phone" => [{"location" => "home", "number
60
70
 
61
71
  Note that the keys are strings. JSI, being designed with JSON in mind, is geared toward string keys. Symbol keys will not match to schema properties, and so act the same as any other key not recognized from the schema.
62
72
 
63
- The nested classes can be seen in the #inspect output as `JSI::SchemaClasses[schema_id]` where schema_id is a generated value.
64
-
65
73
  We get accessors for the Contact:
66
74
 
67
75
  ```ruby
@@ -86,23 +94,25 @@ bill.validate
86
94
  ... and validations on the nested schema instances (#phone here), showing in this example validation failure:
87
95
 
88
96
  ```ruby
89
- bad = Contact.new('phone' => [{'number' => [5, 5, 5]}])
90
- # => #{<Contact Hash>
91
- # "phone" => #[<JSI::SchemaClasses["23d8#/properties/phone"] Array>
92
- # #{<JSI::SchemaClasses["23d8#/properties/phone/items"] Hash>
93
- # "number" => #[<JSI::SchemaClasses["23d8#/properties/phone/items/properties/number"] Array> 5, 5, 5]
97
+ bad = Contact.new_jsi({'phone' => [{'number' => [5, 5, 5]}]})
98
+ # => #{<JSI (Contact)>
99
+ # "phone" => #[<JSI>
100
+ # #{<JSI>
101
+ # "number" => #[<JSI> 5, 5, 5]
94
102
  # }
95
103
  # ]
96
104
  # }
97
105
  bad.phone.fully_validate
98
- # => ["The property '#/0/number' of type array did not match the following type: string in schema 23d8"]
106
+ # => ["The property '#/0/number' of type array did not match the following type: string in schema 594126e3"]
99
107
  ```
100
108
 
101
109
  These validations are done by the [`json-schema` gem](https://github.com/ruby-json-schema/json-schema) - JSI does not do validations on its own.
102
110
 
103
- Since the underlying instance is a ruby hash (json object), we can use it like a hash with #[] or, say, #transform_values:
111
+ Since the underlying instance is a ruby hash (json object), we can use it like a hash with `#[]` or, say, `#transform_values`:
104
112
 
105
113
  ```ruby
114
+ # note that #size here is actually referring to multiple different methods; for name and nickname
115
+ # it is String#size but for phone it is Array#size.
106
116
  bill.transform_values(&:size)
107
117
  # => {"name" => 4, "phone" => 1, "nickname" => 5}
108
118
  bill['nickname']
@@ -115,19 +125,20 @@ There's plenty more JSI has to offer, but this should give you a pretty good ide
115
125
 
116
126
  - `JSI::Base` is the base class for each JSI class representing a JSON Schema.
117
127
  - a "JSI class" is a subclass of `JSI::Base` representing a JSON schema.
128
+ - a "JSI schema module" is a module representing a schema, included on a JSI class.
118
129
  - "instance" is a term that is significantly overloaded in this space, so documentation will attempt to be clear what kind of instance is meant:
119
130
  - a schema instance refers broadly to a data structure that is described by a JSON schema.
120
- - a JSI instance (or just "a JSI") is a ruby object instantiating a JSI class. it has a method #instance which contains the underlying data.
121
- - a schema refers to a JSON schema. a `JSI::Schema` represents such a schema. a JSI class allows instantiation of a schema as a JSI instance.
131
+ - a JSI instance (or just "a JSI") is a ruby object instantiating a JSI class. it has a method `#jsi_instance` which contains the underlying data.
132
+ - a schema refers to a JSON schema. `JSI::Schema` is a module which extends schemas. A schema is usually a `JSI::Base` instance, and that schema JSI's schema is a metaschema (see the sections on Metaschemas below).
122
133
 
123
- ## JSI classes
134
+ ## JSI and Object Oriented Programming
124
135
 
125
- A JSI class (that is, subclass of JSI::Base) is a starting point but obviously you want your own methods, so you reopen the class as you would any other. referring back to the Example section above, we reopen the Contact class:
136
+ Instantiating your schema is a starting point. But, since the major point of object-oriented programming is applying methods to your objects, of course you want to be able to define your own methods. To do this we reopen the JSI module we defined. Referring back to the Example section above, we reopen the `Contact` module:
126
137
 
127
138
  ```ruby
128
- class Contact
129
- def full_address
130
- address.values.join(", ")
139
+ module Contact
140
+ def phone_numbers
141
+ phone.map(&:number)
131
142
  end
132
143
  def name
133
144
  super + ' esq.'
@@ -143,15 +154,17 @@ bill.name = 'rob esq.'
143
154
  # => "rob esq."
144
155
  bill['name']
145
156
  # => "rob"
157
+ bill.phone_numbers
158
+ # => ["555"]
146
159
  ```
147
160
 
148
- Note the use of `super` - you can call to accessors defined by JSI and make your accessors act as wrappers (these accessor methods are defined on an included module instead of the JSI class for this reason). You can also use [] and []=, of course, with the same effect.
161
+ Note the use of `super` - you can call to accessors defined by JSI and make your accessors act as wrappers. You can alternatively use `[]` and `[]=` with the same effect.
149
162
 
150
- If you want to add methods to a subschema, get the class_for_schema for that schema and open up that class. You can leave the class anonymous, as in this example:
163
+ You can also add methods to a subschema using the same method `#jsi_schema_module` which we used to define the `Contact` module above.
151
164
 
152
165
  ```ruby
153
- phone_schema = Contact.schema['properties']['phone']['items']
154
- JSI.class_for_schema(phone_schema).class_eval do
166
+ phone_schema = Contact.schema.properties['phone'].items
167
+ phone_schema.jsi_schema_module.module_eval do
155
168
  def number_with_dashes
156
169
  number.split(//).join('-')
157
170
  end
@@ -160,11 +173,11 @@ bill.phone.first.number_with_dashes
160
173
  # => "5-5-5"
161
174
  ```
162
175
 
163
- If you want to name the class, this works:
176
+ If you want to name the module, this works:
164
177
 
165
178
  ```ruby
166
- Phone = JSI.class_for_schema(Contact.schema['properties']['phone']['items'])
167
- class Phone
179
+ ContactPhone = Contact.schema.properties['phone'].items.jsi_schema_module
180
+ module ContactPhone
168
181
  def number_with_dashes
169
182
  number.split(//).join('-')
170
183
  end
@@ -173,15 +186,23 @@ end
173
186
 
174
187
  Either syntax is slightly cumbersome and a better syntax is in the works.
175
188
 
189
+ ## Metaschemas
190
+
191
+ A metaschema is a schema which describes schemas. Likewise, a schema is an instance of a metaschema.
192
+
193
+ In JSI, a schema is generally a JSI::Base instance whose schema is a metaschema.
194
+
195
+ A self-descriptive metaschema - most commonly one of the JSON schema draft metaschemas - is an object whose schema is itself. This is instantiated in JSI as a JSI::MetaschemaNode (not a JSI::Base).
196
+
176
197
  ## ActiveRecord serialization
177
198
 
178
199
  A really excellent place to use JSI is when dealing with serialized columns in ActiveRecord.
179
200
 
180
- Let's say you're sticking to json types in the database - you have to do so if you're using json columns, or json serialization, and if you have dealt with arbitrary yaml- or marshal-serialized objects in ruby, you have probably found that approach has its shortcomings when the implementation of your classes changes.
201
+ Let's say you're sticking to JSON types in the database - you have to do so if you're using JSON columns, or JSON serialization, and if you have dealt with arbitrary yaml- or marshal-serialized objects in ruby, you have probably found that approach has its shortcomings when the implementation of your classes changes.
181
202
 
182
- But if your database contains json, then your deserialized objects in ruby are likewise Hash / Array / basic types. You have to use subscripts instead of accessors, and you don't have any way to add methods to your data types.
203
+ But if your database contains JSON, then your deserialized objects in ruby are likewise Hash / Array / basic types. You have to use subscripts instead of accessors, and you don't have any way to add methods to your data types.
183
204
 
184
- JSI gives you the best of both with JSICoder. This coder dumps objects which are simple json types, and loads instances of a specified JSI class. Here's an example:
205
+ JSI gives you the best of both with JSICoder. This coder dumps objects which are simple JSON types, and loads instances of a specified JSI schema. Here's an example:
185
206
 
186
207
  ```ruby
187
208
  class User < ActiveRecord::Base
@@ -189,7 +210,7 @@ class User < ActiveRecord::Base
189
210
  end
190
211
  ```
191
212
 
192
- Now `user.contacts` will return an array of Contact instances, from the json type in the database, with Contact's accessors, validations, and user-defined instance methods.
213
+ Now `user.contacts` will return an array of Contact instances, from the JSON type in the database, with Contact's accessors, validations, and user-defined instance methods.
193
214
 
194
215
  See the gem [`arms`](https://github.com/notEthan/arms) if you wish to serialize the dumped JSON-compatible objects further as text.
195
216
 
@@ -203,4 +224,8 @@ Issues and pull requests are welcome on GitHub at https://github.com/notEthan/js
203
224
 
204
225
  ## License
205
226
 
206
- JSI is open source software available under the terms of the [MIT License](https://opensource.org/licenses/MIT).
227
+ [<img align="right" src="https://github.com/notEthan/jsi/raw/master/resources/icons/AGPL-3.0.png">](https://www.gnu.org/licenses/agpl-3.0.html)
228
+
229
+ JSI is Open Source Software licensed under the terms of the [GNU Affero General Public License version 3](https://www.gnu.org/licenses/agpl-3.0.html).
230
+
231
+ Unlike the MIT or BSD licenses more commonly used with Ruby gems, this license requires that if you modify JSI and propagate your changes, e.g. by including it in a web application, your modified version must be publicly available. The common path of forking on Github should satisfy this requirement.
@@ -6,26 +6,22 @@ Gem::Specification.new do |spec|
6
6
  spec.name = "jsi"
7
7
  spec.version = JSI::VERSION
8
8
  spec.authors = ["Ethan"]
9
- spec.email = ["ethan@unth.net"]
9
+ spec.email = ["ethan.jsi@unth.net"]
10
10
 
11
11
  spec.summary = "JSI: JSON Schema Instantiation"
12
12
  spec.description = "JSI offers an Object-Oriented representation for JSON data using JSON Schemas"
13
13
  spec.homepage = "https://github.com/notEthan/jsi"
14
- spec.license = "MIT"
15
- ignore_files = %w(.gitignore .travis.yml Gemfile test)
16
- ignore_files_re = %r{\A(#{ignore_files.map { |f| Regexp.escape(f) }.join('|')})(/|\z)}
17
- Dir.chdir(File.expand_path('..', __FILE__)) do
18
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(ignore_files_re) }
19
- spec.test_files = `git ls-files -z test`.split("\x0")
20
- end
21
- spec.bindir = "exe"
22
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
14
+ spec.license = "AGPL-3.0"
15
+ ignore_files = %w(.gitignore .travis.yml Gemfile test)
16
+ ignore_files_re = %r{\A(#{ignore_files.map { |f| Regexp.escape(f) }.join('|')})(/|\z)}
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(ignore_files_re) }
18
+ spec.test_files = `git ls-files -z test`.split("\x0")
23
19
  spec.require_paths = ["lib"]
24
20
 
25
21
  # we are monkey patching json-schema with a fix that has not been merged in a timely fashion.
26
22
  spec.add_dependency "json-schema", "~> 2.8"
27
- spec.add_development_dependency "rake", "~> 10.0"
28
- spec.add_development_dependency "minitest", "~> 5.0"
23
+ spec.add_development_dependency "rake"
24
+ spec.add_development_dependency "minitest"
29
25
  spec.add_development_dependency "minitest-around"
30
26
  spec.add_development_dependency "minitest-reporters"
31
27
  spec.add_development_dependency "scorpio"
data/lib/jsi.rb CHANGED
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "jsi/version"
2
4
  require "pp"
3
5
  require "set"
6
+ require "pathname"
4
7
  require "jsi/json-schema-fragments"
5
8
  require "jsi/util"
6
9
 
@@ -10,6 +13,9 @@ module JSI
10
13
  class Bug < NotImplementedError
11
14
  end
12
15
 
16
+ ROOT_PATH = Pathname.new(__FILE__).dirname.parent.expand_path
17
+ RESOURCES_PATH = ROOT_PATH.join('resources')
18
+
13
19
  autoload :JSON, 'jsi/json'
14
20
  autoload :PathedNode, 'jsi/pathed_node'
15
21
  autoload :Typelike, 'jsi/typelike_modules'
@@ -19,9 +25,14 @@ module JSI
19
25
  autoload :Base, 'jsi/base'
20
26
  autoload :BaseArray, 'jsi/base'
21
27
  autoload :BaseHash, 'jsi/base'
28
+ autoload :Metaschema, 'jsi/metaschema'
29
+ autoload :MetaschemaNode, 'jsi/metaschema_node'
22
30
  autoload :SchemaClasses, 'jsi/schema_classes'
23
31
  autoload :JSICoder, 'jsi/jsi_coder'
24
32
 
33
+ autoload :JSONSchemaOrgDraft04, 'schemas/json-schema.org/draft-04/schema'
34
+ autoload :JSONSchemaOrgDraft06, 'schemas/json-schema.org/draft-06/schema'
35
+
25
36
  autoload :SimpleWrap, 'jsi/simple_wrap'
26
37
 
27
38
  # @return [Class subclassing JSI::Base] a JSI class which represents the
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'jsi/typelike_modules'
3
5
 
@@ -16,9 +18,20 @@ module JSI
16
18
  include PathedNode
17
19
 
18
20
  class << self
21
+ # JSI::Base.new_jsi behaves the same as .new, and is defined for compatibility so you may call #new_jsi
22
+ # on any of a JSI::Schema, a JSI::SchemaModule, or a JSI schema class.
23
+ # @return [JSI::Base] a JSI whose instance is the given instance
24
+ def new_jsi(instance, *a, &b)
25
+ new(instance, *a, &b)
26
+ end
27
+
19
28
  # is the constant JSI::SchemaClasses::{self.schema_classes_const_name} defined?
20
29
  # (if so, we will prefer to use something more human-readable than that ugly mess.)
21
- attr_accessor :in_schema_classes
30
+ def in_schema_classes
31
+ # #name sets @in_schema_classes
32
+ name
33
+ @in_schema_classes
34
+ end
22
35
 
23
36
  # @return [String] absolute schema_id of the schema this class represents.
24
37
  # see {Schema#schema_id}.
@@ -26,46 +39,39 @@ module JSI
26
39
  schema.schema_id
27
40
  end
28
41
 
29
- # @return [String] a string representing the class, with schema_id
42
+ # @return [String] a string representing the class, with schema_id or schema ptr fragment
30
43
  def inspect
31
- name # see #name for side effects
32
44
  if !respond_to?(:schema)
33
45
  super
34
- elsif in_schema_classes
35
- %Q(#{SchemaClasses.inspect}[#{schema_id.inspect}])
36
- elsif !name
37
- %Q(#<Class for Schema: #{schema_id}>)
38
46
  else
39
- %Q(#{name} (#{schema_id}))
47
+ idfrag = schema_id || schema.node_ptr.fragment
48
+ if name && !in_schema_classes
49
+ "#{name} (#{idfrag})"
50
+ else
51
+ "(JSI Schema Class: #{idfrag})"
52
+ end
40
53
  end
41
54
  end
42
55
 
43
- # @return [String] a string representing the class - a class name if one
44
- # was explicitly defined, otherwise a reference to JSI::SchemaClasses
45
- def to_s
46
- if !respond_to?(:schema)
47
- super
48
- elsif !name || name =~ /\AJSI::SchemaClasses::/
49
- %Q(#{SchemaClasses.inspect}[#{schema_id.inspect}])
50
- else
51
- name
52
- end
53
- end
56
+ alias_method :to_s, :inspect
54
57
 
55
58
  # @return [String] a name for a constant for this class, generated from the
56
59
  # schema_id. only used if the class is not assigned to another constant.
57
60
  def schema_classes_const_name
58
- name = schema.schema_id.gsub(/[^\w]/, '_')
59
- name = 'X' + name unless name[/\A[a-zA-Z]/]
60
- name = name[0].upcase + name[1..-1]
61
- name
61
+ if schema_id
62
+ 'X' + schema_id.gsub(/[^\w]/, '_')
63
+ end
62
64
  end
63
65
 
64
66
  # @return [String] a constant name of this class
65
67
  def name
66
- unless super || SchemaClasses.const_defined?(schema_classes_const_name)
67
- SchemaClasses.const_set(schema_classes_const_name, self)
68
- self.in_schema_classes = true
68
+ unless instance_variable_defined?(:@in_schema_classes)
69
+ if super || !schema_id || SchemaClasses.const_defined?(schema_classes_const_name)
70
+ @in_schema_classes = false
71
+ else
72
+ SchemaClasses.const_set(schema_classes_const_name, self)
73
+ @in_schema_classes = true
74
+ end
69
75
  end
70
76
  super
71
77
  end
@@ -87,17 +93,16 @@ module JSI
87
93
  # @param jsi_ptr [JSI::JSON::Pointer] for internal use. a JSON pointer specifying
88
94
  # the path of this instance in the `jsi_document` param. `jsi_ptr` must be passed
89
95
  # iff `jsi_document` is passed, i.e. when `instance` is `NOINSTANCE`
90
- # @param ancestor_jsi [JSI::Base] for internal use, specifies an ancestor_jsi
91
- # from which this JSI originated to calculate #parents
92
- def initialize(instance, jsi_document: nil, jsi_ptr: nil, ancestor_jsi: nil)
96
+ # @param jsi_root_node [JSI::Base] for internal use, specifies the JSI at the root of the document
97
+ def initialize(instance, jsi_document: nil, jsi_ptr: nil, jsi_root_node: nil)
93
98
  unless respond_to?(:schema)
94
99
  raise(TypeError, "cannot instantiate #{self.class.inspect} which has no method #schema. please use JSI.class_for_schema")
95
100
  end
96
101
 
97
- if instance.is_a?(JSI::Base)
98
- raise(TypeError, "assigning another JSI::Base instance to #{self.class.inspect} instance is incorrect. received: #{instance.pretty_inspect.chomp}")
99
- elsif instance.is_a?(JSI::Schema)
100
- raise(TypeError, "assigning a schema to #{self.class.inspect} instance is incorrect. received: #{instance.pretty_inspect.chomp}")
102
+ if instance.is_a?(JSI::Schema)
103
+ raise(TypeError, "assigning a schema to a #{self.class.inspect} instance is incorrect. received: #{instance.pretty_inspect.chomp}")
104
+ elsif instance.is_a?(JSI::Base)
105
+ raise(TypeError, "assigning another JSI::Base instance to a #{self.class.inspect} instance is incorrect. received: #{instance.pretty_inspect.chomp}")
101
106
  end
102
107
 
103
108
  if instance == NOINSTANCE
@@ -106,33 +111,33 @@ module JSI
106
111
  raise(TypeError, "jsi_ptr must be a JSI::JSON::Pointer; got: #{jsi_ptr.inspect}")
107
112
  end
108
113
  @jsi_ptr = jsi_ptr
109
- else
110
- raise(Bug, 'incorrect usage') if jsi_document || jsi_ptr || ancestor_jsi
111
- if instance.is_a?(PathedNode)
112
- @jsi_document = instance.document_root_node
113
- # this can result in the unusual situation where ancestor_jsi is nil, though jsi_ptr is not root.
114
- # #document_root_node will then return a JSI::JSON::Pointer instead of a root JSI.
115
- @jsi_ptr = instance.node_ptr
114
+ if @jsi_ptr.root?
115
+ raise(Bug, "jsi_root_node cannot be specified for root JSI") if jsi_root_node
116
+ @jsi_root_node = self
116
117
  else
117
- @jsi_document = instance
118
- @jsi_ptr = JSI::JSON::Pointer.new([])
119
- end
120
- end
121
- if ancestor_jsi
122
- if !ancestor_jsi.is_a?(JSI::Base)
123
- raise(TypeError, "ancestor_jsi must be a JSI::Base; got: #{ancestor_jsi.inspect}")
124
- end
125
- if !ancestor_jsi.jsi_ptr.contains?(@jsi_ptr)
126
- raise(Bug, "ancestor_jsi ptr #{ancestor_jsi.jsi_ptr.inspect} is not ancestor of #{@jsi_ptr.inspect}")
118
+ if !jsi_root_node.is_a?(JSI::Base)
119
+ raise(TypeError, "jsi_root_node must be a JSI::Base; got: #{jsi_root_node.inspect}")
120
+ end
121
+ if !jsi_root_node.jsi_ptr.root?
122
+ raise(Bug, "jsi_root_node ptr #{jsi_root_node.jsi_ptr.inspect} is not root")
123
+ end
124
+ @jsi_root_node = jsi_root_node
127
125
  end
126
+ else
127
+ raise(Bug, 'incorrect usage') if jsi_document || jsi_ptr || jsi_root_node
128
+ @jsi_document = instance
129
+ @jsi_ptr = JSI::JSON::Pointer.new([])
130
+ @jsi_root_node = self
128
131
  end
129
- @ancestor_jsi = ancestor_jsi
130
132
 
131
133
  if self.jsi_instance.respond_to?(:to_hash)
132
134
  extend BaseHash
133
135
  elsif self.jsi_instance.respond_to?(:to_ary)
134
136
  extend BaseArray
135
137
  end
138
+ if self.schema.describes_schema?
139
+ extend JSI::Schema
140
+ end
136
141
  end
137
142
 
138
143
  # document containing the instance of this JSI
@@ -141,11 +146,12 @@ module JSI
141
146
  # JSI::JSON::Pointer pointing to this JSI's instance within the jsi_document
142
147
  attr_reader :jsi_ptr
143
148
 
144
- # a JSI which is an ancestor_jsi of this
145
- attr_reader :ancestor_jsi
149
+ # the JSI at the root of this JSI's document
150
+ attr_reader :jsi_root_node
146
151
 
147
152
  alias_method :node_document, :jsi_document
148
153
  alias_method :node_ptr, :jsi_ptr
154
+ alias_method :document_root_node, :jsi_root_node
149
155
 
150
156
  # the instance of the json-schema
151
157
  alias_method :jsi_instance, :node_content
@@ -154,27 +160,18 @@ module JSI
154
160
  # each is overridden by BaseHash or BaseArray when appropriate. the base
155
161
  # #each is not actually implemented, along with all the methods of Enumerable.
156
162
  def each
157
- raise NoMethodError, "Enumerable methods and #each not implemented for instance that is not like a hash or array: #{instance.pretty_inspect.chomp}"
163
+ raise NoMethodError, "Enumerable methods and #each not implemented for instance that is not like a hash or array: #{jsi_instance.pretty_inspect.chomp}"
158
164
  end
159
165
 
160
- # an array of JSI instances above this one in the document. empty if this
161
- # JSI does not have a known ancestor.
166
+ # an array of JSI instances above this one in the document.
162
167
  #
163
168
  # @return [Array<JSI::Base>]
164
169
  def parent_jsis
165
- ancestor_jsi = @ancestor_jsi || self
166
- parent = ancestor_jsi
167
-
168
- (ancestor_jsi.jsi_ptr.reference_tokens.size...self.jsi_ptr.reference_tokens.size).map do |i|
169
- current = parent
170
- parent = parent[self.jsi_ptr.reference_tokens[i]]
171
- if current.is_a?(JSI::Base)
172
- current
173
- else
174
- # sometimes after a deref, we may end up with parents whose schema we do not know.
175
- # TODO this is kinda crap; hopefully we can remove it along with deref instantiating
176
- # a deref ptr as the same JSI class it is
177
- SimpleWrap.new(NOINSTANCE, jsi_document: jsi_document, jsi_ptr: jsi_ptr.take(i), ancestor_jsi: @ancestor_jsi)
170
+ parent = jsi_root_node
171
+
172
+ jsi_ptr.reference_tokens.map do |token|
173
+ parent.tap do
174
+ parent = parent[token]
178
175
  end
179
176
  end.reverse
180
177
  end
@@ -186,40 +183,84 @@ module JSI
186
183
  parent_jsis.first
187
184
  end
188
185
 
189
- # @return [JSI::PathedNode] a pathed node at the root of the document. this is generally a JSI::Base
190
- # but may be a JSI::JSON::Node in unusual circumstances.
191
- def document_root_node
192
- if @jsi_ptr.root?
193
- self
194
- elsif @ancestor_jsi
195
- @ancestor_jsi.document_root_node
196
- elsif instance.is_a?(PathedNode)
197
- instance.document_root_node
186
+ alias_method :parent_node, :parent_jsi
187
+
188
+ # @deprecated
189
+ alias_method :parents, :parent_jsis
190
+ # @deprecated
191
+ alias_method :parent, :parent_jsi
192
+
193
+ # @param token [String, Integer, Object] the token to subscript
194
+ # @return [JSI::Base, Object] the instance's subscript value at the given token.
195
+ # if there is a subschema defined for that token on this JSI's schema,
196
+ # returns that value as a JSI instantiation of that subschema.
197
+ def [](token)
198
+ if respond_to?(:to_hash)
199
+ token_in_range = node_content_hash_pubsend(:key?, token)
200
+ value = node_content_hash_pubsend(:[], token)
201
+ elsif respond_to?(:to_ary)
202
+ token_in_range = node_content_ary_pubsend(:each_index).include?(token)
203
+ value = node_content_ary_pubsend(:[], token)
198
204
  else
199
- JSI::JSON::Node.new_doc(@jsi_document)
205
+ raise(NoMethodError, "cannot subcript (using token: #{token.inspect}) from instance: #{jsi_instance.pretty_inspect.chomp}")
200
206
  end
201
- end
202
207
 
203
- # @return [JSI::PathedNode]
204
- def parent_node
205
- if @jsi_ptr.root?
206
- nil
207
- elsif @ancestor_jsi
208
- parent_jsis.first.tap do |parent_node|
209
- raise(Bug, 'is @ancestor_jsi == self? it should not be') if parent_node.nil?
210
- raise(Bug, "parent_node not PathedNode: #{parent_node.pretty_inspect.chomp}") unless parent_node.is_a?(JSI::PathedNode)
208
+ jsi_memoize(:[], token, value, token_in_range) do |token, value, token_in_range|
209
+ if respond_to?(:to_ary)
210
+ token_schema = schema.subschema_for_index(token)
211
+ else
212
+ token_schema = schema.subschema_for_property(token)
213
+ end
214
+ token_schema = token_schema && token_schema.match_to_instance(value)
215
+
216
+ if token_in_range
217
+ complex_value = token_schema && (value.respond_to?(:to_hash) || value.respond_to?(:to_ary))
218
+ schema_value = token_schema && token_schema.describes_schema?
219
+
220
+ if complex_value || schema_value
221
+ class_for_schema(token_schema).new(Base::NOINSTANCE, jsi_document: @jsi_document, jsi_ptr: @jsi_ptr[token], jsi_root_node: @jsi_root_node)
222
+ else
223
+ value
224
+ end
225
+ else
226
+ defaults = Set.new
227
+ if token_schema
228
+ if token_schema.respond_to?(:to_hash) && token_schema.key?('default')
229
+ defaults << token_schema['default']
230
+ end
231
+ end
232
+
233
+ if defaults.size == 1
234
+ # use the default value
235
+ # we are using #dup so that we get a modified copy of self, in which we set dup[token]=default.
236
+ dup.tap { |o| o[token] = defaults.first }[token]
237
+ else
238
+ # I kind of want to just return nil here. the preferred mechanism for
239
+ # a JSI's default value should be its schema. but returning nil ignores
240
+ # any value returned by Hash#default/#default_proc. there's no compelling
241
+ # reason not to support both, so I'll return that.
242
+ value
243
+ end
211
244
  end
212
- elsif instance.is_a?(PathedNode)
213
- instance.parent_node
214
- else
215
- JSI::JSON::Node.new_by_type(@jsi_document, @jsi_ptr.parent)
216
245
  end
217
246
  end
218
247
 
219
- # @deprecated
220
- alias_method :parents, :parent_jsis
221
- # @deprecated
222
- alias_method :parent, :parent_jsi
248
+ # assigns the subscript of the instance identified by the given token to the given value.
249
+ # if the value is a JSI, its instance is assigned instead of the JSI value itself.
250
+ #
251
+ # @param token [String, Integer, Object] token identifying the subscript to assign
252
+ # @param value [JSI::Base, Object] the value to be assigned
253
+ def []=(token, value)
254
+ unless respond_to?(:to_hash) || respond_to?(:to_ary)
255
+ raise(NoMethodError, "cannot assign subcript (using token: #{token.inspect}) to instance: #{jsi_instance.pretty_inspect.chomp}")
256
+ end
257
+ jsi_clear_memo(:[])
258
+ if value.is_a?(Base)
259
+ self[token] = value.jsi_instance
260
+ else
261
+ jsi_instance[token] = value
262
+ end
263
+ end
223
264
 
224
265
  # if this JSI is a $ref then the $ref is followed. otherwise this JSI
225
266
  # is returned.
@@ -230,19 +271,7 @@ module JSI
230
271
  # @return [JSI::Base, self]
231
272
  def deref(&block)
232
273
  node_ptr_deref do |deref_ptr|
233
- jsi_from_root = deref_ptr.evaluate(document_root_node)
234
- if jsi_from_root.is_a?(JSI::Base)
235
- return jsi_from_root.tap(&(block || Util::NOOP))
236
- else
237
- # TODO I want to get rid of this ... just return jsi_from_root whatever it is
238
- # NOTE when I get rid of this, simplify #parent_jsis too
239
- if @ancestor_jsi && @ancestor_jsi.jsi_ptr.contains?(deref_ptr)
240
- derefed = self.class.new(Base::NOINSTANCE, jsi_document: @jsi_document, jsi_ptr: deref_ptr, ancestor_jsi: @ancestor_jsi)
241
- else
242
- derefed = self.class.new(Base::NOINSTANCE, jsi_document: @jsi_document, jsi_ptr: deref_ptr)
243
- end
244
- return derefed.tap(&(block || Util::NOOP))
245
- end
274
+ deref_ptr.evaluate(jsi_root_node).tap(&(block || Util::NOOP))
246
275
  end
247
276
  return self
248
277
  end
@@ -255,33 +284,25 @@ module JSI
255
284
  # in a (nondestructively) modified copy of this.
256
285
  # @return [JSI::Base subclass the same as self] the modified copy of self
257
286
  def modified_copy(&block)
258
- if @ancestor_jsi
259
- raise(Bug, 'bad @ancestor_jsi') if @ancestor_jsi.object_id == self.object_id
260
-
261
- modified_ancestor = @ancestor_jsi.modified_copy do |anc|
262
- mod_anc = @jsi_ptr.ptr_relative_to(@ancestor_jsi.jsi_ptr).modified_document_copy(anc, &block)
263
- mod_anc
264
- end
265
- self.class.new(Base::NOINSTANCE, jsi_document: modified_ancestor.jsi_document, jsi_ptr: @jsi_ptr, ancestor_jsi: modified_ancestor)
266
- else
287
+ if node_ptr.root?
267
288
  modified_document = @jsi_ptr.modified_document_copy(@jsi_document, &block)
268
289
  self.class.new(Base::NOINSTANCE, jsi_document: modified_document, jsi_ptr: @jsi_ptr)
290
+ else
291
+ modified_jsi_root_node = @jsi_root_node.modified_copy do |root|
292
+ @jsi_ptr.modified_document_copy(root, &block)
293
+ end
294
+ self.class.new(Base::NOINSTANCE, jsi_document: modified_jsi_root_node.node_document, jsi_ptr: @jsi_ptr, jsi_root_node: modified_jsi_root_node)
269
295
  end
270
296
  end
271
297
 
272
- # @return [String] the fragment representation of a pointer to this JSI's instance within its document
273
- def fragment
274
- @jsi_ptr.fragment
275
- end
276
-
277
- # @return [Array<String>] array of schema validation error messages for this instance
278
- def fully_validate
279
- schema.fully_validate_instance(instance)
298
+ # @return [Array] array of schema validation errors for this instance
299
+ def fully_validate(errors_as_objects: false)
300
+ schema.fully_validate_instance(jsi_instance, errors_as_objects: errors_as_objects)
280
301
  end
281
302
 
282
303
  # @return [true, false] whether the instance validates against its schema
283
304
  def validate
284
- schema.validate_instance(instance)
305
+ schema.validate_instance(jsi_instance)
285
306
  end
286
307
 
287
308
  # @return [true] if this method does not raise, it returns true to
@@ -289,7 +310,7 @@ module JSI
289
310
  # @raise [::JSON::Schema::ValidationError] raises if the instance has
290
311
  # validation errors
291
312
  def validate!
292
- schema.validate_instance!(instance)
313
+ schema.validate_instance!(jsi_instance)
293
314
  end
294
315
 
295
316
  def dup
@@ -299,56 +320,77 @@ module JSI
299
320
  # @return [String] a string representing this JSI, indicating its class
300
321
  # and inspecting its instance
301
322
  def inspect
302
- "\#<#{self.class.to_s} #{instance.inspect}>"
323
+ "\#<#{object_group_text.join(' ')} #{jsi_instance.inspect}>"
303
324
  end
304
325
 
305
326
  # pretty-prints a representation this JSI to the given printer
306
327
  # @return [void]
307
328
  def pretty_print(q)
308
- q.instance_exec(self) do |obj|
309
- text "\#<#{obj.class.to_s}"
310
- group_sub {
311
- nest(2) {
312
- breakable ' '
313
- pp obj.instance
314
- }
329
+ q.text '#<'
330
+ q.text object_group_text.join(' ')
331
+ q.group_sub {
332
+ q.nest(2) {
333
+ q.breakable ' '
334
+ q.pp jsi_instance
315
335
  }
316
- breakable ''
317
- text '>'
318
- end
336
+ }
337
+ q.breakable ''
338
+ q.text '>'
319
339
  end
320
340
 
321
341
  # @return [Array<String>]
322
342
  def object_group_text
323
- instance.respond_to?(:object_group_text) ? instance.object_group_text : [instance.class.inspect]
343
+ class_name = self.class.name unless self.class.in_schema_classes
344
+ class_txt = begin
345
+ if class_name
346
+ # ignore ID
347
+ schema_name = schema.jsi_schema_module.name
348
+ if !schema_name
349
+ class_name
350
+ else
351
+ "#{class_name} (#{schema_name})"
352
+ end
353
+ else
354
+ schema_name = schema.jsi_schema_module.name || schema.schema_id
355
+ if !schema_name
356
+ "JSI"
357
+ else
358
+ "JSI (#{schema_name})"
359
+ end
360
+ end
361
+ end
362
+
363
+ if (is_a?(PathedArrayNode) || is_a?(PathedHashNode)) && ![Array, Hash].include?(node_content.class)
364
+ if node_content.respond_to?(:object_group_text)
365
+ node_content_txt = node_content.object_group_text
366
+ else
367
+ node_content_txt = [node_content.class.to_s]
368
+ end
369
+ else
370
+ node_content_txt = []
371
+ end
372
+
373
+ [
374
+ class_txt,
375
+ is_a?(Metaschema) ? "Metaschema" : is_a?(Schema) ? "Schema" : nil,
376
+ *node_content_txt,
377
+ ].compact
324
378
  end
325
379
 
326
380
  # @return [Object] a jsonifiable representation of the instance
327
381
  def as_json(*opt)
328
- Typelike.as_json(instance, *opt)
382
+ Typelike.as_json(jsi_instance, *opt)
329
383
  end
330
384
 
331
385
  # @return [Object] an opaque fingerprint of this JSI for FingerprintHash. JSIs are equal
332
386
  # if their instances are equal, and if the JSIs are of the same JSI class or subclass.
333
- def fingerprint
387
+ def jsi_fingerprint
334
388
  {class: jsi_class, jsi_document: jsi_document, jsi_ptr: jsi_ptr}
335
389
  end
336
390
  include FingerprintHash
337
391
 
338
392
  private
339
393
 
340
- # assigns a subscript, unwrapping a JSI if given.
341
- # @param subscript [Object] the bit between the [ and ]
342
- # @param value [JSI::Base, Object] the value to be assigned
343
- def subscript_assign(subscript, value)
344
- clear_memo(:[])
345
- if value.is_a?(Base)
346
- instance[subscript] = value.instance
347
- else
348
- instance[subscript] = value
349
- end
350
- end
351
-
352
394
  # this is an instance method in order to allow subclasses of JSI classes to
353
395
  # override it to point to other subclasses corresponding to other schemas.
354
396
  def class_for_schema(schema)
@@ -359,114 +401,10 @@ module JSI
359
401
  # module extending a {JSI::Base} object when its instance is Hash-like (responds to #to_hash)
360
402
  module BaseHash
361
403
  include PathedHashNode
362
-
363
- alias_method :jsi_instance_hash_pubsend, :node_content_hash_pubsend
364
-
365
- # @param property_name [String, Object] the property name to subscript
366
- # @return [JSI::Base, Object] the instance's subscript value at the given
367
- # key property_name_. if there is a subschema defined for that property
368
- # on this JSI's schema, returns the instance's subscript as a JSI
369
- # instiation of that subschema.
370
- def [](property_name)
371
- instance_property_key_ = jsi_instance_hash_pubsend(:key?, property_name)
372
- if !instance_property_key_
373
- deref do |deref_jsi|
374
- return deref_jsi[property_name]
375
- end
376
- end
377
- instance_property_value_ = jsi_instance_sub(property_name)
378
- memoize(:[], property_name, instance_property_value_, instance_property_key_) do |property_name_, instance_property_value, instance_property_key|
379
- begin
380
- property_schema = schema.subschema_for_property(property_name_)
381
- property_schema = property_schema && property_schema.match_to_instance(instance_property_value)
382
-
383
- if !instance_property_key && property_schema && property_schema.schema_object.key?('default')
384
- # use the default value
385
- default = property_schema.schema_object['default']
386
- if default.respond_to?(:to_hash) || default.respond_to?(:to_ary)
387
- # we are using #dup so that we get a modified copy of self, in which we set dup[property_name_]=default.
388
- # this avoids duplication of code with #modified_copy and below in #[] to handle pathing and such.
389
- dup.tap { |o| o[property_name_] = default }[property_name_]
390
- else
391
- default
392
- end
393
- elsif property_schema && (instance_property_value.respond_to?(:to_hash) || instance_property_value.respond_to?(:to_ary))
394
- class_for_schema(property_schema).new(Base::NOINSTANCE, jsi_document: @jsi_document, jsi_ptr: @jsi_ptr[property_name_], ancestor_jsi: @ancestor_jsi || self)
395
- else
396
- instance_property_value
397
- end
398
- end
399
- end
400
- end
401
-
402
- # assigns the given property name of the instance to the given value.
403
- # if the value is a JSI, its instance is assigned.
404
- # @param property_name [Object] this should generally be a String, but JSI
405
- # does not enforce any constraint on it.
406
- # @param value [Object] the value to be assigned to the given subscript
407
- # property_name
408
- def []=(property_name, value)
409
- subscript_assign(property_name, value)
410
- end
411
-
412
- private
413
-
414
- # @param token [String, Object]
415
- # @return [Object]
416
- def jsi_instance_sub(token)
417
- jsi_instance_hash_pubsend(:[], token)
418
- end
419
404
  end
420
405
 
421
406
  # module extending a {JSI::Base} object when its instance is Array-like (responds to #to_ary)
422
407
  module BaseArray
423
408
  include PathedArrayNode
424
-
425
- alias_method :jsi_instance_ary_pubsend, :node_content_ary_pubsend
426
-
427
- # @param i [Integer] the array index to subscript
428
- # @return [JSI::Base, Object] the instance's subscript value at the given index
429
- # i. if there is a subschema defined for that index on this JSI's schema,
430
- # returns the instance's subscript as a JSI instiation of that subschema.
431
- def [](i)
432
- memoize(:[], i, jsi_instance_sub(i), jsi_instance_ary_pubsend(:each_index).to_a.include?(i)) do |i_, instance_idx_value, i_in_range|
433
- begin
434
- index_schema = schema.subschema_for_index(i_)
435
- index_schema = index_schema && index_schema.match_to_instance(instance_idx_value)
436
-
437
- if !i_in_range && index_schema && index_schema.schema_object.key?('default')
438
- # use the default value
439
- default = index_schema.schema_object['default']
440
- if default.respond_to?(:to_hash) || default.respond_to?(:to_ary)
441
- # we are using #dup so that we get a modified copy of self, in which we set dup[i]=default.
442
- # this avoids duplication of code with #modified_copy and below in #[] to handle pathing and such.
443
- dup.tap { |o| o[i_] = default }[i_]
444
- else
445
- default
446
- end
447
- elsif index_schema && (instance_idx_value.respond_to?(:to_hash) || instance_idx_value.respond_to?(:to_ary))
448
- class_for_schema(index_schema).new(Base::NOINSTANCE, jsi_document: @jsi_document, jsi_ptr: @jsi_ptr[i_], ancestor_jsi: @ancestor_jsi || self)
449
- else
450
- instance_idx_value
451
- end
452
- end
453
- end
454
- end
455
-
456
- # assigns the given index of the instance to the given value.
457
- # if the value is a JSI, its instance is assigned.
458
- # @param i [Object] the array index to assign
459
- # @param value [Object] the value to be assigned to the given subscript i
460
- def []=(i, value)
461
- subscript_assign(i, value)
462
- end
463
-
464
- private
465
-
466
- # @param token [Integer]
467
- # @return [Object]
468
- def jsi_instance_sub(token)
469
- jsi_instance_ary_pubsend(:[], token)
470
- end
471
409
  end
472
410
  end