Class: JSI::JSON::Node

Inherits:
Object
  • Object
show all
Includes:
FingerprintHash
Defined in:
lib/jsi/json/node.rb

Overview

JSI::JSON::Node is an abstraction of a node within a JSON document. it aims to act like the underlying data type of the node's content (generally Hash or Array-like) in most cases.

the main advantage offered by using a Node over the underlying data is in dereferencing. if a Node consists of a hash with a $ref property pointing within the same document, then the Node will transparently follow the ref and return the referenced data.

in most other respects, a Node aims to act like a Hash when the content is Hash-like, an Array when the content is Array-like. methods of Hash and Array are defined and delegated to the node's content.

however, destructive methods are for the most part not implemented. at the moment only #[]= is implemented. since Node thinly wraps the underlying data, you can change the data and it will be reflected in the node. implementations of destructive methods are planned.

methods that return a modified copy such as #merge are defined, and return a copy of the document with the content of the node modified. the original node's document and content are untouched.

Direct Known Subclasses

ArrayNode, HashNode

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from FingerprintHash

#==, #hash

Constructor Details

#initialize(document, path) ⇒ Node

a Node represents the content of a document at a given path.



46
47
48
49
50
51
52
53
54
55
56
# File 'lib/jsi/json/node.rb', line 46

def initialize(document, path)
  unless path.respond_to?(:to_ary)
    raise(ArgumentError, "path must be an array. got: #{path.pretty_inspect.chomp} (#{path.class})")
  end
  if document.is_a?(JSI::JSON::Node)
    raise(TypeError, "document of a Node should not be another JSI::JSON::Node: #{document.inspect}")
  end
  @document = document
  @path = path.to_ary.dup.freeze
  @pointer = ::JSON::Schema::Pointer.new(:reference_tokens, path)
end

Instance Attribute Details

#documentObject (readonly)

the document containing this Node at is path



61
62
63
# File 'lib/jsi/json/node.rb', line 61

def document
  @document
end

#pathObject (readonly)

the path of this Node within its document



59
60
61
# File 'lib/jsi/json/node.rb', line 59

def path
  @path
end

#pointerObject (readonly)

::JSON::Schema::Pointer representing the path to this node within its document



63
64
65
# File 'lib/jsi/json/node.rb', line 63

def pointer
  @pointer
end

Class Method Details

.new_by_type(document, path) ⇒ Object

if the content of the document at the given path is Hash-like, returns a HashNode; if Array-like, returns ArrayNode. otherwise returns a regular Node, although Nodes are for the most part instantiated from Hash or Array-like content.



33
34
35
36
37
38
39
40
41
42
43
# File 'lib/jsi/json/node.rb', line 33

def self.new_by_type(document, path)
  node = Node.new(document, path)
  content = node.content
  if content.respond_to?(:to_hash)
    HashNode.new(document, path)
  elsif content.respond_to?(:to_ary)
    ArrayNode.new(document, path)
  else
    node
  end
end

.new_doc(document) ⇒ Object



25
26
27
# File 'lib/jsi/json/node.rb', line 25

def self.new_doc(document)
  new_by_type(document, [])
end

Instance Method Details

#[](subscript) ⇒ Object

returns content at the given subscript - call this the subcontent.

if the content cannot be subscripted, raises TypeError.

if the subcontent is Hash-like, it is wrapped as a JSI::JSON::HashNode before being returned. if the subcontent is Array-like, it is wrapped as a JSI::JSON::ArrayNode before being returned.

if this node's content is a $ref - that is, a hash with a $ref attribute - and the subscript is not a key of the hash, then the $ref is followed before returning the subcontent.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/jsi/json/node.rb', line 79

def [](subscript)
  node = self
  content = node.content
  if content.respond_to?(:to_hash) && !(content.respond_to?(:key?) ? content : content.to_hash).key?(subscript)
    node = node.deref
    content = node.content
  end
  unless content.respond_to?(:[])
    if content.respond_to?(:to_hash)
      content = content.to_hash
    elsif content.respond_to?(:to_ary)
      content = content.to_ary
    else
      raise(NoMethodError, "undefined method `[]`\nsubscripting with #{subscript.pretty_inspect.chomp} (#{subscript.class}) from #{content.class.inspect}. content is: #{content.pretty_inspect.chomp}")
    end
  end
  begin
    subcontent = content[subscript]
  rescue TypeError => e
    raise(e.class, e.message + "\nsubscripting with #{subscript.pretty_inspect.chomp} (#{subscript.class}) from #{content.class.inspect}. content is: #{content.pretty_inspect.chomp}", e.backtrace)
  end
  if subcontent.respond_to?(:to_hash)
    HashNode.new(node.document, node.path + [subscript])
  elsif subcontent.respond_to?(:to_ary)
    ArrayNode.new(node.document, node.path + [subscript])
  else
    subcontent
  end
end

#[]=(subscript, value) ⇒ Object

assigns the given subscript of the content to the given value. the document is modified in place.



110
111
112
113
114
115
116
# File 'lib/jsi/json/node.rb', line 110

def []=(subscript, value)
  if value.is_a?(Node)
    content[subscript] = value.content
  else
    content[subscript] = value
  end
end

#as_json(*opt) ⇒ Object

returns a jsonifiable representation of this node's content



175
176
177
# File 'lib/jsi/json/node.rb', line 175

def as_json(*opt)
  Typelike.as_json(content, *opt)
end

#contentObject

the raw content of this Node from the underlying document at this Node's path.



66
67
68
# File 'lib/jsi/json/node.rb', line 66

def content
  pointer.evaluate(document)
end

#derefObject

returns a Node, dereferencing a $ref attribute if possible. if this node is not hash-like, does not have a $ref, or if what its $ref cannot be found, this node is returned.

currently only $refs pointing within the same document are followed.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/jsi/json/node.rb', line 122

def deref
  content = self.content

  if content.respond_to?(:to_hash)
    ref = (content.respond_to?(:[]) ? content : content.to_hash)['$ref']
  end
  return self unless ref.is_a?(String)

  if ref[/\A#/]
    return self.class.new_by_type(document, ::JSON::Schema::Pointer.parse_fragment(ref)).deref
  end

  # HAX for how google does refs and ids
  if document_node['schemas'].respond_to?(:to_hash)
    if document_node['schemas'][ref]
      return document_node['schemas'][ref]
    end
    _, deref_by_id = document_node['schemas'].detect { |_k, schema| schema['id'] == ref }
    if deref_by_id
      return deref_by_id
    end
  end

  #raise(NotImplementedError, "cannot dereference #{ref}") # TODO
  return self
end

#document_nodeObject

a Node at the root of the document



150
151
152
# File 'lib/jsi/json/node.rb', line 150

def document_node
  Node.new_doc(document)
end

#fingerprintObject

fingerprint for equality (see FingerprintHash). two nodes are equal if they are both nodes (regardless of type, e.g. one may be a Node and the other may be a HashNode) within equal documents at equal paths. note that this means two nodes with the same content may not be considered equal.



258
259
260
# File 'lib/jsi/json/node.rb', line 258

def fingerprint
  {is_node: self.is_a?(JSI::JSON::Node), document: document, path: path}
end

#fragmentObject

the pointer fragment to this node within the document, per RFC 6901 https://tools.ietf.org/html/rfc6901



170
171
172
# File 'lib/jsi/json/node.rb', line 170

def fragment
  pointer.fragment
end

#inspectObject

a string representing this node



235
236
237
# File 'lib/jsi/json/node.rb', line 235

def inspect
  "\#<#{self.class.inspect} #{object_group_text} #{content.inspect}>"
end

#modified_copyObject

takes a block. the block is yielded the content of this node. the block MUST return a modified copy of that content (and NOT modify the object it is given).



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/jsi/json/node.rb', line 181

def modified_copy
  # we need to preserve the rest of the document, but modify the content at our path.
  #
  # this is actually a bit tricky. we can't modify the original document, obviously.
  # we could do a deep copy, but that's expensive. instead, we make a copy of each array
  # or hash in the path above this node. this node's content is modified by the caller, and
  # that is recursively merged up to the document root. the recursion is done with a
  # y combinator, for no other reason than that was a fun way to implement it.
  modified_document = JSI::Util.ycomb do |rec|
    proc do |subdocument, subpath|
      if subpath == []
        yield(subdocument)
      else
        car = subpath[0]
        cdr = subpath[1..-1]
        if subdocument.respond_to?(:to_hash)
          subdocument_car = (subdocument.respond_to?(:[]) ? subdocument : subdocument.to_hash)[car]
          car_object = rec.call(subdocument_car, cdr)
          if car_object.object_id == subdocument_car.object_id
            subdocument
          else
            (subdocument.respond_to?(:merge) ? subdocument : subdocument.to_hash).merge({car => car_object})
          end
        elsif subdocument.respond_to?(:to_ary)
          if car.is_a?(String) && car =~ /\A\d+\z/
            car = car.to_i
          end
          unless car.is_a?(Integer)
            raise(TypeError, "bad subscript #{car.pretty_inspect.chomp} with remaining subpath: #{cdr.inspect} for array: #{subdocument.pretty_inspect.chomp}")
          end
          subdocument_car = (subdocument.respond_to?(:[]) ? subdocument : subdocument.to_ary)[car]
          car_object = rec.call(subdocument_car, cdr)
          if car_object.object_id == subdocument_car.object_id
            subdocument
          else
            (subdocument.respond_to?(:[]=) ? subdocument : subdocument.to_ary).dup.tap do |arr|
              arr[car] = car_object
            end
          end
        else
          raise(TypeError, "bad subscript: #{car.pretty_inspect.chomp} with remaining subpath: #{cdr.inspect} for content: #{subdocument.pretty_inspect.chomp}")
        end
      end
    end
  end.call(document, path)
  Node.new_by_type(modified_document, path)
end

#object_group_textObject

meta-information about the object, outside the content. used by #inspect / #pretty_print



230
231
232
# File 'lib/jsi/json/node.rb', line 230

def object_group_text
  "fragment=#{fragment.inspect}" + (content.respond_to?(:object_group_text) ? ' ' + content.object_group_text : '')
end

#parent_nodeObject

the parent of this node. if this node is the document root (its path is empty), raises ::JSON::Schema::Pointer::ReferenceError.



156
157
158
159
160
161
162
# File 'lib/jsi/json/node.rb', line 156

def parent_node
  if path.empty?
    raise(::JSON::Schema::Pointer::ReferenceError, "cannot access parent of root node: #{pretty_inspect.chomp}")
  else
    Node.new_by_type(document, path[0...-1])
  end
end

#pointer_pathObject

the pointer path to this node within the document, per RFC 6901 https://tools.ietf.org/html/rfc6901



165
166
167
# File 'lib/jsi/json/node.rb', line 165

def pointer_path
  pointer.pointer
end

#pretty_print(q) ⇒ Object

pretty-prints a representation this node to the given printer



240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/jsi/json/node.rb', line 240

def pretty_print(q)
  q.instance_exec(self) do |obj|
    text "\#<#{obj.class.inspect} #{obj.object_group_text}"
    group_sub {
      nest(2) {
        breakable ' '
        pp obj.content
      }
    }
    breakable ''
    text '>'
  end
end