diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..6dbfe70d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: test +on: +- push +- pull_request +jobs: + test: + strategy: + fail-fast: false + matrix: + ruby-version: + - '2.6' + - '2.7' + - '3.2' + - '3.1' + - '3.0' + - ruby-head + - jruby-head + - truffleruby-head + runs-on: + - ubuntu-latest + + runs-on: ${{ matrix.runs-on }} + + steps: + + - uses: actions/checkout@v2 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - run: bundle exec rake test diff --git a/.gitignore b/.gitignore index 79744e1b..12f524a8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ Gemfile.lock coverage/* doc/* .yardoc +.DS_Store +.idea +vendor +.tags +*.gem diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..cc32da4b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1 @@ +inherit_from: .rubocop_todo.yml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 00000000..ea5082db --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,127 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2019-01-25 09:23:04 +0100 using RuboCop version 0.63.1. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 15 +# Configuration parameters: AllowSafeAssignment. +Lint/AssignmentInCondition: + Exclude: + - 'lib/jsonpath.rb' + - 'lib/jsonpath/parser.rb' + +# Offense count: 1 +Lint/IneffectiveAccessModifier: + Exclude: + - 'lib/jsonpath.rb' + +# Offense count: 17 +Metrics/AbcSize: + Max: 60 + +# Offense count: 2 +# Configuration parameters: CountComments, ExcludedMethods. +# ExcludedMethods: refine +Metrics/BlockLength: + Max: 37 + +# Offense count: 1 +# Configuration parameters: CountBlocks. +Metrics/BlockNesting: + Max: 4 + +# Offense count: 3 +# Configuration parameters: CountComments. +Metrics/ClassLength: + Max: 739 + +# Offense count: 7 +Metrics/CyclomaticComplexity: + Max: 20 + +# Offense count: 26 +# Configuration parameters: CountComments, ExcludedMethods. +Metrics/MethodLength: + Max: 52 + +# Offense count: 1 +# Configuration parameters: CountKeywordArgs. +Metrics/ParameterLists: + Max: 6 + +# Offense count: 6 +Metrics/PerceivedComplexity: + Max: 21 + +# Offense count: 1 +# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. +# AllowedNames: io, id, to, by, on, in, at, ip, db +Naming/UncommunicativeMethodParamName: + Exclude: + - 'lib/jsonpath/parser.rb' + +# Offense count: 15 +# Configuration parameters: AllowedChars. +Style/AsciiComments: + Exclude: + - 'lib/jsonpath/parser.rb' + +# Offense count: 2 +Style/Documentation: + Exclude: + - 'spec/**/*' + - 'test/**/*' + - 'lib/jsonpath/enumerable.rb' + - 'lib/jsonpath/proxy.rb' + +# Offense count: 3 +# Configuration parameters: MinBodyLength. +Style/GuardClause: + Exclude: + - 'lib/jsonpath/enumerable.rb' + - 'lib/jsonpath/parser.rb' + +# Offense count: 2 +# Cop supports --auto-correct. +Style/IfUnlessModifier: + Exclude: + - 'lib/jsonpath/enumerable.rb' + +# Offense count: 1 +Style/MultipleComparison: + Exclude: + - 'lib/jsonpath/parser.rb' + +# Offense count: 3 +# Cop supports --auto-correct. +# Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Exclude: + - 'spec/**/*' + - 'lib/jsonpath/enumerable.rb' + - 'lib/jsonpath/parser.rb' + +# Offense count: 3 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, AllowInnerSlashes. +# SupportedStyles: slashes, percent_r, mixed +Style/RegexpLiteral: + Exclude: + - 'lib/jsonpath/parser.rb' + +# Offense count: 4 +# Cop supports --auto-correct. +Style/RescueModifier: + Exclude: + - 'lib/jsonpath/enumerable.rb' + - 'lib/jsonpath/parser.rb' + +# Offense count: 89 +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. +# URISchemes: http, https +Metrics/LineLength: + Max: 296 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e10ddff0..00000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -rvm: - - 1.9.2 - - 1.9.3 - - 2.0.0 - - jruby diff --git a/Gemfile b/Gemfile index 34efd140..f60c3549 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,6 @@ -source "http://rubygems.org" -gemspec \ No newline at end of file +# frozen_string_literal: true + +source 'http://rubygems.org' +gemspec +# gem 'rubocop', require: true, group: :test +gem 'simplecov', require: false, group: :test diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..b45beba9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Joshua Lin & Gergely Brautigam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0e43a6bd..638a1049 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ This is an implementation of http://goessner.net/articles/JsonPath/. ## What is JsonPath? -JsonPath is a way of addressing elements within a JSON object. Similar to xpath of yore, JsonPath lets you -traverse a json object and manipulate or access it. +JsonPath is a way of addressing elements within a JSON object. Similar to xpath +of yore, JsonPath lets you traverse a json object and manipulate or access it. ## Usage @@ -15,14 +15,14 @@ There is stand-alone usage through the binary `jsonpath` jsonpath [expression] (file|string) - If you omit the second argument, it will read stdin, assuming one valid JSON object - per line. Expression must be a valid jsonpath expression. + If you omit the second argument, it will read stdin, assuming one valid JSON + object per line. Expression must be a valid jsonpath expression. ### Library To use JsonPath as a library simply include and get goin'! -~~~~~ {ruby} +```ruby require 'jsonpath' json = <<-HERE_DOC @@ -38,90 +38,212 @@ json = <<-HERE_DOC } } HERE_DOC -~~~~~ +``` -Now that we have a JSON object, let's get all the prices present in the object. We create an object for the path -in the following way. +Now that we have a JSON object, let's get all the prices present in the object. +We create an object for the path in the following way. -~~~~~ {ruby} +```ruby path = JsonPath.new('$..price') -~~~~~ +``` Now that we have a path, let's apply it to the object above. -~~~~~ {ruby} +```ruby path.on(json) # => [19.95, 8.95, 12.99, 8.99, 22.99] -~~~~~ +``` -Or on some other object ... +Or reuse it later on some other object (thread safe) ... -~~~~~ {ruby} +```ruby path.on('{"books":[{"title":"A Tale of Two Somethings","price":18.88}]}') # => [18.88] -~~~~~ +``` -You can also just combine this into one mega-call with the convenient `JsonPath.on` method. +You can also just combine this into one mega-call with the convenient +`JsonPath.on` method. -~~~~~ {ruby} +```ruby JsonPath.on(json, '$..author') # => ["Nigel Rees", "Evelyn Waugh", "Herman Melville", "Tolkien"] -~~~~~ +``` Of course the full JsonPath syntax is supported, such as array slices -~~~~~ {ruby} +```ruby JsonPath.new('$..book[::2]').on(json) # => [ -# {"price"=>8.95, "category"=>"reference", "author"=>"Nigel Rees", "title"=>"Sayings of the Century"}, -# {"price"=>8.99, "category"=>"fiction", "author"=>"Herman Melville", "title"=>"Moby Dick", "isbn"=>"0-553-21311-3"} +# {"price" => 8.95, "category" => "reference", "title" => "Sayings of the Century", "author" => "Nigel Rees"}, +# {"price" => 8.99, "category" => "fiction", "isbn" => "0-553-21311-3", "title" => "Moby Dick", "author" => "Herman Melville","color" => "blue"}, # ] -~~~~~ +``` -...and evals. +...and evals, including those with conditional operators -~~~~~ {ruby} -JsonPath.new('$..price[?(@ < 20)]').on(json) +```ruby +JsonPath.new("$..price[?(@ < 10)]").on(json) # => [8.95, 8.99] -~~~~~ -There is a convenience method, `#first` that gives you the first element for a JSON object and path. +JsonPath.new("$..book[?(@['price'] == 8.95 || @['price'] == 8.99)].title").on(json) +# => ["Sayings of the Century", "Moby Dick"] -~~~~~ {ruby} -JsonPath.new('$..color').first(object) +JsonPath.new("$..book[?(@['price'] == 8.95 && @['price'] == 8.99)].title").on(json) +# => [] +``` + +There is a convenience method, `#first` that gives you the first element for a +JSON object and path. + +```ruby +JsonPath.new('$..color').first(json) # => "red" -~~~~~ +``` As well, we can directly create an `Enumerable` at any time using `#[]`. -~~~~~ {ruby} -enum = JsonPath.new('$..color')[object] +```ruby +enum = JsonPath.new('$..color')[json] # => # enum.first # => "red" enum.any?{ |c| c == 'red' } # => true -~~~~~ +``` + +For more usage examples and variations on paths, please visit the tests. There +are some more complex ones as well. + +### Querying ruby data structures + +If you have ruby hashes with symbolized keys as input, you +can use `:use_symbols` to make JsonPath work fine on them too: + +```ruby +book = { title: "Sayings of the Century" } + +JsonPath.new('$.title').on(book) +# => [] + +JsonPath.new('$.title', use_symbols: true).on(book) +# => ["Sayings of the Century"] +``` + +JsonPath also recognizes objects responding to `dig` (introduced +in ruby 2.3), and therefore works out of the box with Struct, +OpenStruct, and other Hash-like structures: + +```ruby +book_class = Struct.new(:title) +book = book_class.new("Sayings of the Century") + +JsonPath.new('$.title').on(book) +# => ["Sayings of the Century"] +``` + +JsonPath is able to query pure ruby objects and uses `__send__` +on them. The option is enabled by default in JsonPath 1.x, but +we encourage to enable it explicitly: + +```ruby +book_class = Class.new{ attr_accessor :title } +book = book_class.new +book.title = "Sayings of the Century" + +JsonPath.new('$.title', allow_send: true).on(book) +# => ["Sayings of the Century"] +``` + +### Other available options + +By default, JsonPath does not return null values on unexisting paths. +This can be changed using the `:default_path_leaf_to_null` option + +```ruby +JsonPath.new('$..book[*].isbn').on(json) +# => ["0-553-21311-3", "0-395-19395-8"] + +JsonPath.new('$..book[*].isbn', default_path_leaf_to_null: true).on(json) +# => [nil, nil, "0-553-21311-3", "0-395-19395-8"] +``` + +When JsonPath returns a Hash, you can ask to symbolize its keys +using the `:symbolize_keys` option + +```ruby +JsonPath.new('$..book[0]').on(json) +# => [{"category" => "reference", ...}] -You can optionally prevent eval from being called on sub-expressions by passing in :allow_eval => false to the constructor. +JsonPath.new('$..book[0]', symbolize_keys: true).on(json) +# => [{category: "reference", ...}] +``` + +### Selecting Values + +It's possible to select results once a query has been defined after the query. For +example given this JSON data: + +```bash +{ + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + } + ] +} +``` + +... and this query: + +```ruby +"$.store.book[*](category,author)" +``` + +... the result can be filtered as such: + +```bash +[ + { + "category" : "reference", + "author" : "Nigel Rees" + }, + { + "category" : "fiction", + "author" : "Evelyn Waugh" + } +] +``` ### Manipulation -If you'd like to do substitution in a json object, you can use `#gsub` or `#gsub!` to modify the object in place. +If you'd like to do substitution in a json object, you can use `#gsub` +or `#gsub!` to modify the object in place. -~~~~~ {ruby} +```ruby JsonPath.for('{"candy":"lollipop"}').gsub('$..candy') {|v| "big turks" }.to_hash -~~~~~ +``` The result will be -~~~~~ {ruby} +```ruby {'candy' => 'big turks'} -~~~~~ +``` -If you'd like to remove all nil keys, you can use `#compact` and `#compact!`. To remove all keys under a certain path, use `#delete` or `#delete!`. You can even chain these methods together as follows: +If you'd like to remove all nil keys, you can use `#compact` and `#compact!`. +To remove all keys under a certain path, use `#delete` or `#delete!`. You can +even chain these methods together as follows: -~~~~~ {ruby} +```ruby json = '{"candy":"lollipop","noncandy":null,"other":"things"}' o = JsonPath.for(json). gsub('$..candy') {|v| "big turks" }. @@ -129,4 +251,51 @@ o = JsonPath.for(json). delete('$..other'). to_hash # => {"candy" => "big turks"} -~~~~~ +``` + +### Fetch all paths + +To fetch all possible paths in given json, you can use `fetch_all_path`` method. + +data: + +```bash +{ + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees" + }, + { + "category": "fiction", + "author": "Evelyn Waugh" + } + ] +} +``` + +... and this query: + +```ruby +JsonPath.fetch_all_path(data) +``` + +... the result will be: + +```bash +["$", "$.store", "$.store.book", "$.store.book[0].category", "$.store.book[0].author", "$.store.book[0]", "$.store.book[1].category", "$.store.book[1].author", "$.store.book[1]"] +``` + + + +# Contributions + +Please feel free to submit an Issue or a Pull Request any time you feel like +you would like to contribute. Thank you! + +## Running an individual test + +```ruby +ruby -Ilib:../lib test/test_jsonpath.rb --name test_wildcard_on_intermediary_element_v6 +``` diff --git a/Rakefile b/Rakefile index 39f6501a..672e204d 100644 --- a/Rakefile +++ b/Rakefile @@ -1,12 +1,23 @@ +# frozen_string_literal: true + +desc 'run rubocop' +task(:rubocop) do + require 'rubocop' + cli = RuboCop::CLI.new + cli.run +end + +require 'simplecov' +SimpleCov.start do + add_filter '/test/' +end + require 'bundler' Bundler::GemHelper.install_tasks task :test do - $: << 'lib' - require 'minitest/autorun' - require 'phocus' - require 'jsonpath' + $LOAD_PATH << 'lib' Dir['./test/**/test_*.rb'].each { |test| require test } end -task :default => :test \ No newline at end of file +task default: %i[test rubocop] diff --git a/bin/jsonpath b/bin/jsonpath index eef811c6..72aab9ac 100755 --- a/bin/jsonpath +++ b/bin/jsonpath @@ -1,4 +1,5 @@ -#!/usr/local/env ruby +#!/usr/bin/env ruby +# frozen_string_literal: true require 'jsonpath' require 'multi_json' @@ -15,7 +16,7 @@ usage unless ARGV[0] jsonpath = JsonPath.new(ARGV[0]) case ARGV[1] -when nil #stdin +when nil # stdin puts MultiJson.encode(jsonpath.on(MultiJson.decode(STDIN.read))) when String puts MultiJson.encode(jsonpath.on(MultiJson.decode(File.exist?(ARGV[1]) ? File.read(ARGV[1]) : ARGV[1]))) diff --git a/jsonpath.gemspec b/jsonpath.gemspec index 7ff16b0e..9ecd57a7 100644 --- a/jsonpath.gemspec +++ b/jsonpath.gemspec @@ -1,32 +1,28 @@ -# -*- encoding: utf-8 -*- +# frozen_string_literal: true require File.join(File.dirname(__FILE__), 'lib', 'jsonpath', 'version') Gem::Specification.new do |s| s.name = 'jsonpath' s.version = JsonPath::VERSION - s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= - s.authors = ["Joshua Hull"] - s.summary = "Ruby implementation of http://goessner.net/articles/JsonPath/" - s.description = "Ruby implementation of http://goessner.net/articles/JsonPath/." - s.email = %q{joshbuddy@gmail.com} + s.required_ruby_version = '>= 2.6' + s.authors = ['Joshua Hull', 'Gergely Brautigam'] + s.summary = 'Ruby implementation of http://goessner.net/articles/JsonPath/' + s.description = 'Ruby implementation of http://goessner.net/articles/JsonPath/.' + s.email = ['joshbuddy@gmail.com', 'skarlso777@gmail.com'] s.extra_rdoc_files = ['README.md'] s.files = `git ls-files`.split("\n") - s.homepage = %q{http://github.com/joshbuddy/jsonpath} - s.rdoc_options = ["--charset=UTF-8"] - s.require_paths = ["lib"] - s.rubygems_version = %q{1.3.7} - s.test_files = `git ls-files`.split("\n").select{|f| f =~ /^spec/} - s.rubyforge_project = 'jsonpath' - s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + s.homepage = 'https://github.com/joshbuddy/jsonpath' + s.test_files = `git ls-files`.split("\n").select { |f| f =~ /^spec/ } + s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.licenses = ['MIT'] # dependencies s.add_runtime_dependency 'multi_json' + s.add_development_dependency 'bundler' s.add_development_dependency 'code_stats' - s.add_development_dependency 'rake' s.add_development_dependency 'minitest', '~> 2.2.0' s.add_development_dependency 'phocus' - s.add_development_dependency 'bundler' + s.add_development_dependency 'racc' + s.add_development_dependency 'rake' end - diff --git a/lib/jsonpath.rb b/lib/jsonpath.rb index 24658004..0388f62b 100644 --- a/lib/jsonpath.rb +++ b/lib/jsonpath.rb @@ -1,59 +1,124 @@ +# frozen_string_literal: true + require 'strscan' require 'multi_json' require 'jsonpath/proxy' +require 'jsonpath/dig' require 'jsonpath/enumerable' require 'jsonpath/version' +require 'jsonpath/parser' +# JsonPath: initializes the class with a given JsonPath and parses that path +# into a token array. class JsonPath - PATH_ALL = '$..*' + MAX_NESTING_ALLOWED = 100 - attr_reader :path + DEFAULT_OPTIONS = { + :default_path_leaf_to_null => false, + :symbolize_keys => false, + :use_symbols => false, + :allow_send => true, + :max_nesting => MAX_NESTING_ALLOWED + } - def initialize(path, opts = nil) - @opts = opts - scanner = StringScanner.new(path) + attr_accessor :path + + def initialize(path, opts = {}) + @opts = DEFAULT_OPTIONS.merge(opts) + set_max_nesting + scanner = StringScanner.new(path.strip) @path = [] - bracket_count = 0 - while not scanner.eos? - if token = scanner.scan(/\$/) - @path << token - elsif token = scanner.scan(/@/) + until scanner.eos? + if (token = scanner.scan(/\$\B|@\B|\*|\.\./)) @path << token - elsif token = scanner.scan(/[a-zA-Z0-9_-]+/) + elsif (token = scanner.scan(/[$@\p{Alnum}:{}_ -]+/)) @path << "['#{token}']" - elsif token = scanner.scan(/'(.*?)'/) + elsif (token = scanner.scan(/'(.*?)'/)) @path << "[#{token}]" - elsif token = scanner.scan(/\[/) - count = 1 - while !count.zero? - if t = scanner.scan(/\[/) - token << t - count += 1 - elsif t = scanner.scan(/\]/) - token << t - count -= 1 - elsif t = scanner.scan(/[^\[\]]*/) - token << t - end - end - @path << token - elsif token = scanner.scan(/\.\./) + elsif (token = scanner.scan(/\[/)) + @path << find_matching_brackets(token, scanner) + elsif (token = scanner.scan(/\]/)) + raise ArgumentError, 'unmatched closing bracket' + elsif (token = scanner.scan(/\(.*\)/)) @path << token elsif scanner.scan(/\./) nil - elsif token = scanner.scan(/\*/) - @path << token - elsif token = scanner.scan(/[><=] \d+/) + elsif (token = scanner.scan(/[><=] \d+/)) @path.last << token - elsif token = scanner.scan(/./) + elsif (token = scanner.scan(/./)) @path.last << token + else + raise ArgumentError, "character '#{scanner.peek(1)}' not supported in query" + end + end + end + + def find_matching_brackets(token, scanner) + count = 1 + until count.zero? + if (t = scanner.scan(/\[/)) + token << t + count += 1 + elsif (t = scanner.scan(/\]/)) + token << t + count -= 1 + elsif (t = scanner.scan(/[^\[\]]+/)) + token << t + elsif scanner.eos? + raise ArgumentError, 'unclosed bracket' + end + end + token + end + + def join(join_path) + res = deep_clone + res.path += JsonPath.new(join_path).path + res + end + + def on(obj_or_str, opts = {}) + a = enum_on(obj_or_str).to_a + if symbolize_keys?(opts) + a.map! do |e| + e.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v; } + end + end + a + end + + def self.fetch_all_path(obj) + all_paths = ['$'] + find_path(obj, '$', all_paths, obj.class == Array) + return all_paths + end + + def self.find_path(obj, root_key, all_paths, is_array = false) + obj.each do |key, value| + table_params = { key: key, root_key: root_key} + is_loop = value.class == Array || value.class == Hash + if is_loop + path_exp = construct_path(table_params) + all_paths << path_exp + find_path(value, path_exp, all_paths, value.class == Array) + elsif is_array + table_params[:index] = obj.find_index(key) + path_exp = construct_path(table_params) + find_path(key, path_exp, all_paths, key.class == Array) if key.class == Hash || key.class == Array + all_paths << path_exp + else + all_paths << construct_path(table_params) end end end - def on(obj_or_str) - enum_on(obj_or_str).to_a + def self.construct_path(table_row) + if table_row[:index] + return table_row[:root_key] + '['+ table_row[:index].to_s + ']' + else + return table_row[:root_key] + '.'+ table_row[:key] + end end def first(obj_or_str, *args) @@ -61,12 +126,13 @@ def first(obj_or_str, *args) end def enum_on(obj_or_str, mode = nil) - JsonPath::Enumerable.new(self, self.class.process_object(obj_or_str), mode, @opts) + JsonPath::Enumerable.new(self, self.class.process_object(obj_or_str, @opts), mode, + @opts) end - alias_method :[], :enum_on + alias [] enum_on - def self.on(obj_or_str, path, opts = nil) - self.new(path, opts).on(process_object(obj_or_str)) + def self.on(obj_or_str, path, opts = {}) + new(path, opts).on(process_object(obj_or_str)) end def self.for(obj_or_str) @@ -74,7 +140,21 @@ def self.for(obj_or_str) end private - def self.process_object(obj_or_str) - obj_or_str.is_a?(String) ? MultiJson.decode(obj_or_str) : obj_or_str + + def self.process_object(obj_or_str, opts = {}) + obj_or_str.is_a?(String) ? MultiJson.decode(obj_or_str, max_nesting: opts[:max_nesting]) : obj_or_str + end + + def deep_clone + Marshal.load Marshal.dump(self) + end + + def set_max_nesting + return unless @opts[:max_nesting].is_a?(Integer) && @opts[:max_nesting] > MAX_NESTING_ALLOWED + @opts[:max_nesting] = false + end + + def symbolize_keys?(opts) + opts.fetch(:symbolize_keys, @opts&.dig(:symbolize_keys)) end end diff --git a/lib/jsonpath/dig.rb b/lib/jsonpath/dig.rb new file mode 100644 index 00000000..7a130042 --- /dev/null +++ b/lib/jsonpath/dig.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class JsonPath + module Dig + + # Similar to what Hash#dig or Array#dig + def dig(context, *keys) + keys.inject(context){|memo,k| + dig_one(memo, k) + } + end + + # Returns a hash mapping each key from keys + # to its dig value on context. + def dig_as_hash(context, keys) + keys.each_with_object({}) do |k, memo| + memo[k] = dig_one(context, k) + end + end + + # Dig the value of k on context. + def dig_one(context, k) + case context + when Hash + context[@options[:use_symbols] ? k.to_sym : k] + when Array + context[k.to_i] + else + if context.respond_to?(:dig) + context.dig(k) + elsif @options[:allow_send] + context.__send__(k) + end + end + end + + # Yields the block if context has a diggable + # value for k + def yield_if_diggable(context, k, &blk) + case context + when Array + nil + when Hash + k = @options[:use_symbols] ? k.to_sym : k + return yield if context.key?(k) || @options[:default_path_leaf_to_null] + else + if context.respond_to?(:dig) + digged = dig_one(context, k) + yield if !digged.nil? || @options[:default_path_leaf_to_null] + elsif @options[:allow_send] && context.respond_to?(k.to_s) && !Object.respond_to?(k.to_s) + yield + end + end + end + + end +end \ No newline at end of file diff --git a/lib/jsonpath/enumerable.rb b/lib/jsonpath/enumerable.rb index 89b37022..29660abe 100644 --- a/lib/jsonpath/enumerable.rb +++ b/lib/jsonpath/enumerable.rb @@ -1,95 +1,145 @@ +# frozen_string_literal: true + class JsonPath class Enumerable include ::Enumerable - attr_reader :allow_eval - alias_method :allow_eval?, :allow_eval + include Dig - def initialize(path, object, mode, options = nil) - @path, @object, @mode, @options = path.path, object, mode, options - @allow_eval = @options && @options.key?(:allow_eval) ? @options[:allow_eval] : true + def initialize(path, object, mode, options = {}) + @path = path.path + @object = object + @mode = mode + @options = options end def each(context = @object, key = nil, pos = 0, &blk) - node = key ? context[key] : context + node = key ? dig_one(context, key) : context @_current_node = node return yield_value(blk, context, key) if pos == @path.size + case expr = @path[pos] - when '*', '..' + when '*', '..', '@' each(context, key, pos + 1, &blk) when '$' - each(context, key, pos + 1, &blk) if node == @object - when '@' - each(context, key, pos + 1, &blk) + if node == @object + each(context, key, pos + 1, &blk) + else + handle_wildcard(node, "['#{expr}']", context, key, pos, &blk) + end when /^\[(.*)\]$/ - expr[1,expr.size - 2].split(',').each do |sub_path| - case sub_path[0] - when ?', ?" - if node.is_a?(Hash) - k = sub_path[1,sub_path.size - 2] - each(node, k, pos + 1, &blk) if node.key?(k) - end - when ?? - raise "Cannot use ?(...) unless eval is enabled" unless allow_eval? - case node - when Hash, Array - (node.is_a?(Hash) ? node.keys : (0..node.size)).each do |e| - @_current_node = node[e] - if process_function_or_literal(sub_path[1, sub_path.size - 1]) - each(@_current_node, nil, pos + 1, &blk) - end - end - else - yield node if process_function_or_literal(sub_path[1, sub_path.size - 1]) - end + handle_wildcard(node, expr, context, key, pos, &blk) + when /\(.*\)/ + keys = expr.gsub(/[()]/, '').split(',').map(&:strip) + new_context = filter_context(context, keys) + yield_value(blk, new_context, key) + end + + if pos > 0 && @path[pos - 1] == '..' || (@path[pos - 1] == '*' && @path[pos] != '..') + case node + when Hash then node.each { |k, _| each(node, k, pos, &blk) } + when Array then node.each_with_index { |_, i| each(node, i, pos, &blk) } + end + end + end + + private + + def filter_context(context, keys) + case context + when Hash + dig_as_hash(context, keys) + when Array + context.each_with_object([]) do |c, memo| + memo << dig_as_hash(c, keys) + end + end + end + + def handle_wildcard(node, expr, _context, _key, pos, &blk) + expr[1, expr.size - 2].split(',').each do |sub_path| + case sub_path[0] + when '\'', '"' + k = sub_path[1, sub_path.size - 2] + yield_if_diggable(node, k) do + each(node, k, pos + 1, &blk) + end + when '?' + handle_question_mark(sub_path, node, pos, &blk) + else + next if node.is_a?(Array) && node.empty? + next if node.nil? # when default_path_leaf_to_null is true + next if node.size.zero? + + array_args = sub_path.split(':') + if array_args[0] == '*' + start_idx = 0 + end_idx = node.size - 1 + elsif sub_path.count(':') == 0 + start_idx = end_idx = process_function_or_literal(array_args[0], 0) + next unless start_idx + next if start_idx >= node.size else - if node.is_a?(Array) - next if node.empty? - array_args = sub_path.split(':') - if array_args[0] == ?* - start_idx = 0 - end_idx = node.size - 1 - else - start_idx = process_function_or_literal(array_args[0], 0) - next unless start_idx - end_idx = (array_args[1] && process_function_or_literal(array_args[1], -1) || (sub_path.count(':') == 0 ? start_idx : -1)) - next unless end_idx - if start_idx == end_idx - next unless start_idx < node.size - end - end - start_idx %= node.size - end_idx %= node.size - step = process_function_or_literal(array_args[2], 1) - next unless step - (start_idx..end_idx).step(step) {|i| each(node, i, pos + 1, &blk)} - end + start_idx = process_function_or_literal(array_args[0], 0) + next unless start_idx + + end_idx = array_args[1] && ensure_exclusive_end_index(process_function_or_literal(array_args[1], -1)) || -1 + next unless end_idx + next if start_idx == end_idx && start_idx >= node.size end - end - else - if pos == (@path.size - 1) && node && allow_eval? - if eval("node #{@path[pos]}") - yield_value(blk, context, key) + + start_idx %= node.size + end_idx %= node.size + step = process_function_or_literal(array_args[2], 1) + next unless step + + if @mode == :delete + (start_idx..end_idx).step(step) { |i| node[i] = nil } + node.compact! + else + (start_idx..end_idx).step(step) { |i| each(node, i, pos + 1, &blk) } end end end + end - if pos > 0 && @path[pos-1] == '..' - case node - when Hash then node.each {|k, v| each(node, k, pos, &blk) } - when Array then node.each_with_index {|n, i| each(node, i, pos, &blk) } + def ensure_exclusive_end_index(value) + return value unless value.is_a?(Integer) && value > 0 + + value - 1 + end + + def handle_question_mark(sub_path, node, pos, &blk) + case node + when Array + node.size.times do |index| + @_current_node = node[index] + if process_function_or_literal(sub_path[1, sub_path.size - 1]) + each(@_current_node, nil, pos + 1, &blk) + end end + when Hash + if process_function_or_literal(sub_path[1, sub_path.size - 1]) + each(@_current_node, nil, pos + 1, &blk) + end + else + yield node if process_function_or_literal(sub_path[1, sub_path.size - 1]) end end - private def yield_value(blk, context, key) case @mode when nil - blk.call(key ? context[key] : context) + blk.call(key ? dig_one(context, key) : context) when :compact - context.delete(key) if key && context[key].nil? + if key && context[key].nil? + key.is_a?(Integer) ? context.delete_at(key) : context.delete(key) + end when :delete - context.delete(key) if key + if key + key.is_a?(Integer) ? context.delete_at(key) : context.delete(key) + else + context.replace({}) + end when :substitute if key context[key] = blk.call(context[key]) @@ -100,28 +150,21 @@ def yield_value(blk, context, key) end def process_function_or_literal(exp, default = nil) - if exp.nil? - default - elsif exp[0] == ?( - return nil unless allow_eval? && @_current_node - match_result = /@\.(\p{Word}+)/.match(exp) || [] - identifier = match_result[1] - # if there's no such method - convert into hash subscript - if !identifier.nil? && !@_current_node.methods.include?(identifier.to_sym) - exp_to_eval = exp.gsub(/@/, '@_current_node').gsub(/@_current_node.#{identifier}/,"@_current_node['#{identifier}']") - begin - return eval(exp_to_eval) - rescue StandardError # if eval failed because of bad arguments or missing methods - return default - end + return default if exp.nil? || exp.empty? + return Integer(exp) if exp[0] != '(' + return nil unless @_current_node + + identifiers = /@?(((?0} + # {"@['isTrue']"=>true} + def construct_expression_map(exps) + exps.each_with_index do |item, _index| + next if item == '&&' || item == '||' + + item = item.strip.gsub(/\)*$/, '').gsub(/^\(*/, '') + @_expr_map[item] = parse_exp(item) + end + end + + # Using a scanner break down the individual expressions and determine if + # there is a match in the JSON for it or not. + def parse_exp(exp) + exp = exp.sub(/@/, '').gsub(/^\(/, '').gsub(/\)$/, '').tr('"', '\'').strip + exp.scan(/^\[(\d+)\]/) do |i| + next if i.empty? + + index = Integer(i[0]) + raise ArgumentError, 'Node does not appear to be an array.' unless @_current_node.is_a?(Array) + raise ArgumentError, "Index out of bounds for nested array. Index: #{index}" if @_current_node.size < index + + @_current_node = @_current_node[index] + # Remove the extra '' and the index. + exp = exp.gsub(/^\[\d+\]|\[''\]/, '') + end + scanner = StringScanner.new(exp) + elements = [] + until scanner.eos? + if (t = scanner.scan(/\['[a-zA-Z@&*\/$%^?_]+'\]|\.[a-zA-Z0-9_]+[?]?/)) + elements << t.gsub(/[\[\]'.]|\s+/, '') + elsif (t = scanner.scan(/(\s+)?[<>=!\-+][=~]?(\s+)?/)) + operator = t + elsif (t = scanner.scan(/(\s+)?'?.*'?(\s+)?/)) + # If we encounter a node which does not contain `'` it means + #  that we are dealing with a boolean type. + operand = + if t == 'true' + true + elsif t == 'false' + false + elsif operator.to_s.strip == '=~' + parse_regex(t) + else + t.gsub(%r{^'|'$}, '').strip + end + elsif (t = scanner.scan(/\/\w+\//)) + elsif (t = scanner.scan(/.*/)) + raise "Could not process symbol: #{t}" + end + end + + el = if elements.empty? + @_current_node + elsif @_current_node.is_a?(Hash) + dig(@_current_node, *elements) + else + elements.inject(@_current_node, &:__send__) + end + + return (el ? true : false) if el.nil? || operator.nil? + + el = Float(el) rescue el + operand = Float(operand) rescue operand + + el.__send__(operator.strip, operand) + end + + private + + # /foo/i -> Regex.new("foo", Regexp::IGNORECASE) without using eval + # also supports %r{foo}i + # following https://github.com/seamusabshere/to_regexp/blob/master/lib/to_regexp.rb + def parse_regex(t) + t =~ REGEX + content = $1 || $3 + options = $2 || $4 + + raise ArgumentError, "unsupported regex #{t} use /foo/ style" if !content || !options + + content = content.gsub '\\/', '/' + + flags = 0 + flags |= Regexp::IGNORECASE if options.include?('i') + flags |= Regexp::MULTILINE if options.include?('m') + flags |= Regexp::EXTENDED if options.include?('x') + + # 'n' = none, 'e' = EUC, 's' = SJIS, 'u' = UTF-8 + lang = options.scan(/[nes]/).join.downcase # ignores u since that is default and causes a warning + + args = [content, flags] + args << lang unless lang.empty? # avoid warning + Regexp.new(*args) + end + + #  This will break down a parenthesis from the left to the right + #  and replace the given expression with it's returned value. + # It does this in order to make it easy to eliminate groups + # one-by-one. + def parse_parentheses(str) + opening_index = 0 + closing_index = 0 + + (0..str.length - 1).step(1) do |i| + opening_index = i if str[i] == '(' + if str[i] == ')' + closing_index = i + break + end + end + + to_parse = str[opening_index + 1..closing_index - 1] + + #  handle cases like (true && true || false && true) in + # one giant parenthesis. + top = to_parse.split(/(&&)|(\|\|)/) + top = top.map(&:strip) + res = bool_or_exp(top.shift) + top.each_with_index do |item, index| + if item == '&&' + next_value = bool_or_exp(top[index + 1]) + res &&= next_value + elsif item == '||' + next_value = bool_or_exp(top[index + 1]) + res ||= next_value + end + end + + #  if we are at the last item, the opening index will be 0 + # and the closing index will be the last index. To avoid + # off-by-one errors we simply return the result at that point. + if closing_index + 1 >= str.length && opening_index == 0 + res.to_s + else + "#{str[0..opening_index - 1]}#{res}#{str[closing_index + 1..str.length]}" + end + end + + #  This is convoluted and I should probably refactor it somehow. + #  The map that is created will contain strings since essentially I'm + # constructing a string like `true || true && false`. + # With eval the need for this would disappear but never the less, here + #  it is. The fact is that the results can be either boolean, or a number + # in case there is only indexing happening like give me the 3rd item... or + # it also can be nil in case of regexes or things that aren't found. + # Hence, I have to be clever here to see what kind of variable I need to + # provide back. + def bool_or_exp(b) + if b.to_s == 'true' + return true + elsif b.to_s == 'false' + return false + elsif b.to_s == '' + return nil + end + + b = Float(b) rescue b + b + end + + # this simply makes sure that we aren't getting into the whole + #  parenthesis parsing business without knowing that every parenthesis + # has its pair. + def check_parenthesis_count(exp) + return true unless exp.include?('(') + + depth = 0 + exp.chars.each do |c| + if c == '(' + depth += 1 + elsif c == ')' + depth -= 1 + end + end + depth == 0 + end + end +end diff --git a/lib/jsonpath/proxy.rb b/lib/jsonpath/proxy.rb index 4e8f1b7e..3d11a8b3 100644 --- a/lib/jsonpath/proxy.rb +++ b/lib/jsonpath/proxy.rb @@ -1,18 +1,20 @@ +# frozen_string_literal: true + class JsonPath class Proxy attr_reader :obj - alias_method :to_hash, :obj + alias to_hash obj def initialize(obj) @obj = obj end def gsub(path, replacement = nil, &replacement_block) - _gsub(_deep_copy, path, replacement ? proc{replacement} : replacement_block) + _gsub(_deep_copy, path, replacement ? proc(&method(:replacement)) : replacement_block) end def gsub!(path, replacement = nil, &replacement_block) - _gsub(@obj, path, replacement ? proc{replacement} : replacement_block) + _gsub(@obj, path, replacement ? proc(&method(:replacement)) : replacement_block) end def delete(path = JsonPath::PATH_ALL) @@ -32,8 +34,9 @@ def compact!(path = JsonPath::PATH_ALL) end private + def _deep_copy - Marshal::load(Marshal::dump(@obj)) + Marshal.load(Marshal.dump(@obj)) end def _gsub(obj, path, replacement) @@ -43,12 +46,22 @@ def _gsub(obj, path, replacement) def _delete(obj, path) JsonPath.new(path)[obj, :delete].each + obj = _remove(obj) Proxy.new(obj) end + def _remove(obj) + obj.each do |o| + if o.is_a?(Hash) || o.is_a?(Array) + _remove(o) + o.delete({}) + end + end + end + def _compact(obj, path) JsonPath.new(path)[obj, :compact].each Proxy.new(obj) end end -end \ No newline at end of file +end diff --git a/lib/jsonpath/version.rb b/lib/jsonpath/version.rb index 97c8bda9..db0675c5 100644 --- a/lib/jsonpath/version.rb +++ b/lib/jsonpath/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class JsonPath - VERSION = '0.5.6' -end \ No newline at end of file + VERSION = '1.1.5' +end diff --git a/test/test_jsonpath.rb b/test/test_jsonpath.rb index 40667b75..80fa0bb8 100644 --- a/test/test_jsonpath.rb +++ b/test/test_jsonpath.rb @@ -1,12 +1,24 @@ -class TestJsonpath < MiniTest::Unit::TestCase +# frozen_string_literal: true + +require 'minitest/autorun' +require 'phocus' +require 'jsonpath' +require 'json' +class TestJsonpath < MiniTest::Unit::TestCase def setup @object = example_object @object2 = example_object end + def test_bracket_matching + assert_raises(ArgumentError) { JsonPath.new('$.store.book[0') } + assert_raises(ArgumentError) { JsonPath.new('$.store.book[0]]') } + assert_equal [9], JsonPath.new('$.store.book[0].price').on(@object) + end + def test_lookup_direct_path - assert_equal 4, JsonPath.new('$.store.*').on(@object).first['book'].size + assert_equal 7, JsonPath.new('$.store.*').on(@object).first['book'].size end def test_lookup_missing_element @@ -18,7 +30,10 @@ def test_retrieve_all_authors @object['store']['book'][0]['author'], @object['store']['book'][1]['author'], @object['store']['book'][2]['author'], - @object['store']['book'][3]['author'] + @object['store']['book'][3]['author'], + @object['store']['book'][4]['author'], + @object['store']['book'][5]['author'], + @object['store']['book'][6]['author'] ], JsonPath.new('$..author').on(@object) end @@ -33,16 +48,21 @@ def test_retrieve_all_prices end def test_recognize_array_splices - assert_equal [@object['store']['book'][0], @object['store']['book'][1]], JsonPath.new('$..book[0:1:1]').on(@object) - assert_equal [@object['store']['book'][1], @object['store']['book'][3]], JsonPath.new('$..book[1::2]').on(@object) - assert_equal [@object['store']['book'][0], @object['store']['book'][2]], JsonPath.new('$..book[::2]').on(@object) - assert_equal [@object['store']['book'][0], @object['store']['book'][2]], JsonPath.new('$..book[:-2:2]').on(@object) - assert_equal [@object['store']['book'][2], @object['store']['book'][3]], JsonPath.new('$..book[2::]').on(@object) + assert_equal [@object['store']['book'][0]], JsonPath.new('$..book[0:1:1]').on(@object) + assert_equal [@object['store']['book'][0], @object['store']['book'][1]], JsonPath.new('$..book[0:2:1]').on(@object) + assert_equal [@object['store']['book'][1], @object['store']['book'][3], @object['store']['book'][5]], JsonPath.new('$..book[1::2]').on(@object) + assert_equal [@object['store']['book'][0], @object['store']['book'][2], @object['store']['book'][4], @object['store']['book'][6]], JsonPath.new('$..book[::2]').on(@object) + assert_equal [@object['store']['book'][0], @object['store']['book'][2]], JsonPath.new('$..book[:-5:2]').on(@object) + assert_equal [@object['store']['book'][5], @object['store']['book'][6]], JsonPath.new('$..book[5::]').on(@object) + end + + def test_slice_array_with_exclusive_end_correctly + assert_equal [@object['store']['book'][0], @object['store']['book'][1]], JsonPath.new('$..book[:2]').on(@object) end def test_recognize_array_comma assert_equal [@object['store']['book'][0], @object['store']['book'][1]], JsonPath.new('$..book[0,1]').on(@object) - assert_equal [@object['store']['book'][2], @object['store']['book'][3]], JsonPath.new('$..book[2,-1::]').on(@object) + assert_equal [@object['store']['book'][2], @object['store']['book'][6]], JsonPath.new('$..book[2,-1::]').on(@object) end def test_recognize_filters @@ -52,14 +72,71 @@ def test_recognize_filters assert_equal [@object['store']['book'][3]], JsonPath.new("$..book[?(@['price'] > 20)]").on(@object) end - if RUBY_VERSION[/^1\.9/] - def test_recognize_filters_on_val - assert_equal [@object['store']['book'][1]['price'], @object['store']['book'][3]['price'], @object['store']['bicycle']['price']], JsonPath.new("$..price[?(@ > 10)]").on(@object) - end + def test_not_equals_operator + expected = + [ + @object['store']['book'][0], + @object['store']['book'][4], + @object['store']['book'][5], + @object['store']['book'][6] + ] + assert_equal(expected, JsonPath.new("$..book[?(@['category'] != 'fiction')]").on(@object)) + assert_equal(expected, JsonPath.new("$..book[?(@['category']!=fiction)]").on(@object)) + assert_equal(expected, JsonPath.new("$..book[?(@.category!=fiction)]").on(@object)) + assert_equal(expected, JsonPath.new("$..book[?(@.category != 'fiction')]").on(@object)) end - def test_no_eval - assert_equal [], JsonPath.new('$..book[(@.length-2)]', :allow_eval => false).on(@object) + def test_or_operator + assert_equal [@object['store']['book'][1], @object['store']['book'][3]], JsonPath.new("$..book[?(@['price'] == 13 || @['price'] == 23)]").on(@object) + result = ["Sayings of the Century", "Sword of Honour", "Moby Dick", "The Lord of the Rings"] + assert_equal result, JsonPath.new("$..book[?(@.price==13 || @.price==9 || @.price==23)].title").on(@object) + assert_equal result, JsonPath.new("$..book[?(@.price==9 || @.price==23 || @.price==13)].title").on(@object) + assert_equal result, JsonPath.new("$..book[?(@.price==23 || @.price==13 || @.price==9)].title").on(@object) + end + + def test_or_operator_with_not_equals + # Should be the same regardless of key style ( @.key vs @['key'] ) + result = ['Nigel Rees', 'Evelyn Waugh', 'Herman Melville', 'J. R. R. Tolkien', 'Lukyanenko'] + assert_equal result, JsonPath.new("$..book[?(@['title']=='Osennie Vizity' || @['author']!='Lukyanenko')].author").on(@object) + assert_equal result, JsonPath.new("$..book[?(@.title=='Osennie Vizity' || @.author != Lukyanenko )].author").on(@object) + assert_equal result, JsonPath.new("$..book[?(@.title=='Osennie Vizity' || @.author!=Lukyanenko )].author").on(@object) + end + + def test_and_operator + assert_equal [], JsonPath.new("$..book[?(@['price'] == 13 && @['price'] == 23)]").on(@object) + assert_equal [], JsonPath.new("$..book[?(@.price>=13 && @.category==fiction && @.title==no_match)]").on(@object) + assert_equal [], JsonPath.new("$..book[?(@.title==no_match && @.category==fiction && @.price==13)]").on(@object) + assert_equal [], JsonPath.new("$..book[?(@.price==13 && @.title==no_match && @.category==fiction)]").on(@object) + assert_equal [], JsonPath.new("$..book[?(@.price==13 && @.bad_key_name==true && @.category==fiction)]").on(@object) + + expected = [@object['store']['book'][1]] + assert_equal expected, JsonPath.new("$..book[?(@['price'] < 23 && @['price'] > 9)]").on(@object) + assert_equal expected, JsonPath.new("$..book[?(@.price < 23 && @.price > 9)]").on(@object) + + expected = ['Sword of Honour', 'The Lord of the Rings'] + assert_equal expected, JsonPath.new("$..book[?(@.price>=13 && @.category==fiction)].title").on(@object) + assert_equal ['The Lord of the Rings'], JsonPath.new("$..book[?(@.category==fiction && @.isbn && @.price>9)].title").on(@object) + assert_equal ['Sayings of the Century'], JsonPath.new("$..book[?(@['price'] == 9 && @.author=='Nigel Rees')].title").on(@object) + assert_equal ['Sayings of the Century'], JsonPath.new("$..book[?(@['price'] == 9 && @.tags..asdf)].title").on(@object) + end + + def test_and_operator_with_not_equals + expected = ['Nigel Rees'] + assert_equal expected, JsonPath.new("$..book[?(@['price']==9 && @['category']!=fiction)].author").on(@object) + assert_equal expected, JsonPath.new("$..book[?(@.price==9 && @.category!=fiction)].author").on(@object) + end + + def test_nested_grouping + path = "$..book[?((@['price'] == 19 && @['author'] == 'Herman Melville') || @['price'] == 23)]" + assert_equal [@object['store']['book'][3]], JsonPath.new(path).on(@object) + end + + def test_eval_with_floating_point_and_and + assert_equal [@object['store']['book'][1]], JsonPath.new("$..book[?(@['price'] < 23.0 && @['price'] > 9.0)]").on(@object) + end + + def test_eval_with_floating_point + assert_equal [@object['store']['book'][1]], JsonPath.new("$..book[?(@['price'] == 13.0)]").on(@object) end def test_paths_with_underscores @@ -67,33 +144,122 @@ def test_paths_with_underscores end def test_path_with_hyphens - assert_equal [@object['store']['bicycle']['single-speed']], JsonPath.new('$.store.bicycle.single-speed').on(@object) + assert_equal [@object['store']['bicycle']['single-speed']], JsonPath.new('$.store.bicycle.single-speed').on(@object) + end + + def test_path_with_colon + assert_equal [@object['store']['bicycle']['make:model']], JsonPath.new('$.store.bicycle.make:model').on(@object) end def test_paths_with_numbers assert_equal [@object['store']['bicycle']['2seater']], JsonPath.new('$.store.bicycle.2seater').on(@object) end + def test_recognized_dot_notation_in_filters + assert_equal [@object['store']['book'][2], @object['store']['book'][3]], JsonPath.new('$..book[?(@.isbn)]').on(@object) + end + + def test_works_on_non_hash + klass = Struct.new(:a, :b) + object = klass.new('some', 'value') + + assert_equal ['value'], JsonPath.new('$.b').on(object) + end + + def test_works_on_object + klass = Class.new{ + attr_reader :b + def initialize(b) + @b = b + end + } + object = klass.new("value") + + assert_equal ["value"], JsonPath.new('$.b').on(object) + end + + def test_works_on_object_can_be_disabled + klass = Class.new{ + attr_reader :b + def initialize(b) + @b = b + end + } + object = klass.new("value") + + assert_equal [], JsonPath.new('$.b', allow_send: false).on(object) + end + + def test_works_on_diggable + klass = Class.new{ + attr_reader :h + def initialize(h) + @h = h + end + def dig(*keys) + @h.dig(*keys) + end + } + + object = klass.new('a' => 'some', 'b' => 'value') + assert_equal ['value'], JsonPath.new('$.b').on(object) + + object = { + "foo" => klass.new('a' => 'some', 'b' => 'value') + } + assert_equal ['value'], JsonPath.new('$.foo.b').on(object) + end + + def test_works_on_non_hash_with_filters + klass = Struct.new(:a, :b) + first_object = klass.new('some', 'value') + second_object = klass.new('next', 'other value') + + assert_equal ['other value'], JsonPath.new('$[?(@.a == "next")].b').on([first_object, second_object]) + end + + def test_works_on_hash_with_summary + object = { + "foo" => [{ + "a" => "some", + "b" => "value" + }] + } + assert_equal [{ "b" => "value" }], JsonPath.new("$.foo[*](b)").on(object) + end + + def test_works_on_non_hash_with_summary + klass = Struct.new(:a, :b) + object = { + "foo" => [klass.new("some", "value")] + } + assert_equal [{ "b" => "value" }], JsonPath.new("$.foo[*](b)").on(object) + end + def test_recognize_array_with_evald_index - assert_equal [@object['store']['book'][2]], JsonPath.new('$..book[(@.length-2)]').on(@object) + assert_equal [@object['store']['book'][2]], JsonPath.new('$..book[(@.length-5)]').on(@object) end def test_use_first - assert_equal @object['store']['book'][2], JsonPath.new('$..book[(@.length-2)]').first(@object) + assert_equal @object['store']['book'][2], JsonPath.new('$..book[(@.length-5)]').first(@object) end def test_counting - assert_equal 31, JsonPath.new('$..*').on(@object).to_a.size + assert_equal 59, JsonPath.new('$..*').on(@object).to_a.size end def test_space_in_path - assert_equal ['e'], JsonPath.new("$.'c d'").on({"a" => "a","b" => "b", "c d" => "e"}) + assert_equal ['e'], JsonPath.new("$.'c d'").on('a' => 'a', 'b' => 'b', 'c d' => 'e') end def test_class_method assert_equal JsonPath.new('$..author').on(@object), JsonPath.on(@object, '$..author') end + def test_join + assert_equal JsonPath.new('$.store.book..author').on(@object), JsonPath.new('$.store').join('book..author').on(@object) + end + def test_gsub @object2['store']['bicycle']['price'] += 10 @object2['store']['book'][0]['price'] += 10 @@ -113,78 +279,1043 @@ def test_gsub! end def test_weird_gsub! - h = {'hi' => 'there'} - JsonPath.for(@object).gsub!('$.*') { |n| h } + h = { 'hi' => 'there' } + JsonPath.for(@object).gsub!('$.*') { |_| h } assert_equal h, @object end + def test_gsub_to_false! + h = { 'hi' => 'there' } + h2 = { 'hi' => false } + assert_equal h2, JsonPath.for(h).gsub!('$.hi') { |_| false }.to_hash + end + + def test_where_selector + JsonPath.for(@object).gsub!('$..book.price[?(@ > 20)]') { |p| p + 10 } + end + def test_compact - h = {'hi' => 'there', 'you' => nil} + h = { 'hi' => 'there', 'you' => nil } JsonPath.for(h).compact! - assert_equal({'hi' => 'there'}, h) + assert_equal({ 'hi' => 'there' }, h) end def test_delete - h = {'hi' => 'there', 'you' => nil} + h = { 'hi' => 'there', 'you' => nil } JsonPath.for(h).delete!('*.hi') - assert_equal({'you' => nil}, h) + assert_equal({ 'you' => nil }, h) + end + + def test_delete_2 + json = { 'store' => { + 'book' => [ + { 'category' => 'reference', + 'author' => 'Nigel Rees', + 'title' => 'Sayings of the Century', + 'price' => 9, + 'tags' => %w[asdf asdf2] }, + { 'category' => 'fiction', + 'author' => 'Evelyn Waugh', + 'title' => 'Sword of Honour', + 'price' => 13 }, + { 'category' => 'fiction', + 'author' => 'Aasdf', + 'title' => 'Aaasdf2', + 'price' => 1 } + ] + } } + json_deleted = { 'store' => { + 'book' => [ + { 'category' => 'fiction', + 'author' => 'Evelyn Waugh', + 'title' => 'Sword of Honour', + 'price' => 13 }, + { 'category' => 'fiction', + 'author' => 'Aasdf', + 'title' => 'Aaasdf2', + 'price' => 1 } + ] + } } + assert_equal(json_deleted, JsonPath.for(json).delete("$..store.book[?(@.category == 'reference')]").obj) + end + + def test_delete_3 + json = { 'store' => { + 'book' => [ + { 'category' => 'reference', + 'author' => 'Nigel Rees', + 'title' => 'Sayings of the Century', + 'price' => 9, + 'tags' => %w[asdf asdf2], + 'this' => { + 'delete_me' => [ + 'no' => 'do not' + ] + } }, + { 'category' => 'fiction', + 'author' => 'Evelyn Waugh', + 'title' => 'Sword of Honour', + 'price' => 13 }, + { 'category' => 'fiction', + 'author' => 'Aasdf', + 'title' => 'Aaasdf2', + 'price' => 1 } + ] + } } + json_deleted = { 'store' => { + 'book' => [ + { 'category' => 'reference', + 'author' => 'Nigel Rees', + 'title' => 'Sayings of the Century', + 'price' => 9, + 'tags' => %w[asdf asdf2], + 'this' => {} }, + { 'category' => 'fiction', + 'author' => 'Evelyn Waugh', + 'title' => 'Sword of Honour', + 'price' => 13 }, + { 'category' => 'fiction', + 'author' => 'Aasdf', + 'title' => 'Aaasdf2', + 'price' => 1 } + ] + } } + assert_equal(json_deleted, JsonPath.for(json).delete('$..store.book..delete_me').obj) + end + + def test_delete_for_array + before = JsonPath.on(@object, '$..store.book[1]') + JsonPath.for(@object).delete!('$..store.book[0]') + after = JsonPath.on(@object, '$..store.book[0]') + assert_equal(after, before, 'Before is the second element. After should have been equal to the next element after delete.') + end + + def test_at_sign_in_json_element + data = + { '@colors' => + [{ '@r' => 255, '@g' => 0, '@b' => 0 }, + { '@r' => 0, '@g' => 255, '@b' => 0 }, + { '@r' => 0, '@g' => 0, '@b' => 255 }] } + + assert_equal [255, 0, 0], JsonPath.on(data, '$..@r') end def test_wildcard - assert_equal @object['store']['book'].collect{|e| e['price']}, JsonPath.on(@object, '$..book[*].price') + assert_equal @object['store']['book'].collect { |e| e['price'] }.compact, JsonPath.on(@object, '$..book[*].price') + end + + def test_wildcard_on_intermediary_element + assert_equal [1], JsonPath.on({ 'a' => { 'b' => { 'c' => 1 } } }, '$.a..c') + end + + def test_wildcard_on_intermediary_element_v2 + assert_equal [1], JsonPath.on({ 'a' => { 'b' => { 'd' => { 'c' => 1 } } } }, '$.a..c') + end + + def test_wildcard_on_intermediary_element_v3 + assert_equal [1], JsonPath.on({ 'a' => { 'b' => { 'd' => { 'c' => 1 } } } }, '$.a.*..c') + end + + def test_wildcard_on_intermediary_element_v4 + assert_equal [1], JsonPath.on({ 'a' => { 'b' => { 'd' => { 'c' => 1 } } } }, '$.a.*..c') + end + + def test_wildcard_on_intermediary_element_v5 + assert_equal [1], JsonPath.on({ 'a' => { 'b' => { 'c' => 1 } } }, '$.a.*.c') + end + + def test_wildcard_on_intermediary_element_v6 + assert_equal ['red'], JsonPath.new('$.store.*.color').on(@object) end def test_wildcard_empty_array - object = @object.merge("bicycle" => { "tire" => [] }) - assert_equal [], JsonPath.on(object, "$..bicycle.tire[*]") + object = @object.merge('bicycle' => { 'tire' => [] }) + assert_equal [], JsonPath.on(object, '$..bicycle.tire[*]') end - def test_support_filter_by_childnode_value - assert_equal [@object['store']['book'][3]], JsonPath.new("$..book[?(@.price > 20)]").on(@object) + def test_support_filter_by_array_childnode_value + assert_equal [@object['store']['book'][3]], JsonPath.new('$..book[?(@.price > 20)]').on(@object) end def test_support_filter_by_childnode_value_with_inconsistent_children - @object['store']['book'][0] = "string_instead_of_object" - assert_equal [@object['store']['book'][3]], JsonPath.new("$..book[?(@.price > 20)]").on(@object) + @object['store']['book'][0] = 'string_instead_of_object' + assert_equal [@object['store']['book'][3]], JsonPath.new('$..book[?(@.price > 20)]').on(@object) end def test_support_filter_by_childnode_value_and_select_child_key - assert_equal [23], JsonPath.new("$..book[?(@.price > 20)].price").on(@object) + assert_equal [23], JsonPath.new('$..book[?(@.price > 20)].price').on(@object) end - def example_object - { "store"=> { - "book" => [ - { "category"=> "reference", - "author"=> "Nigel Rees", - "title"=> "Sayings of the Century", - "price"=> 9 + def test_support_filter_by_childnode_value_over_childnode_and_select_child_key + assert_equal ['Osennie Vizity'], JsonPath.new('$..book[?(@.written.year == 1996)].title').on(@object) + end + + def test_support_filter_by_object_childnode_value + data = { + 'data' => { + 'type' => 'users', + 'id' => '123' + } + } + assert_equal [{ 'type' => 'users', 'id' => '123' }], JsonPath.new("$.data[?(@.type == 'users')]").on(data) + assert_equal [], JsonPath.new("$.[?(@.type == 'admins')]").on(data) + end + + def test_support_at_sign_in_member_names + assert_equal [@object['store']['@id']], JsonPath.new('$.store.@id').on(@object) + end + + def test_support_dollar_sign_in_member_names + assert_equal [@object['store']['$meta-data']], + JsonPath.new('$.store.$meta-data').on(@object) + end + + def test_support_underscore_in_member_names + assert_equal [@object['store']['_links']], + JsonPath.new('$.store._links').on(@object) + end + + def test_support_for_umlauts_in_member_names + assert_equal [@object['store']['Übermorgen']], + JsonPath.new('$.store.Übermorgen').on(@object) + end + + def test_support_for_spaces_in_member_name + assert_equal [@object['store']['Title Case']], + JsonPath.new('$.store.Title Case').on(@object) + end + + def test_dig_return_string + assert_equal ['asdf'], JsonPath.new("$.store.book..tags[?(@ == 'asdf')]").on(@object) + assert_equal [], JsonPath.new("$.store.book..tags[?(@ == 'not_asdf')]").on(@object) + end + + def test_slash_in_value + data = { + 'data' => [{ + 'type' => 'mps/awesome' + }, { + 'type' => 'not' + }] + } + assert_equal [{ 'type' => 'mps/awesome' }], JsonPath.new('$.data[?(@.type == "mps/awesome")]').on(data) + end + + def test_floating_point_with_precision_marker + data = { + 'data' => { + 'type' => 0.00001 + } + } + assert_equal [{ 'type' => 0.00001 }], JsonPath.new('$.data[?(@.type == 0.00001)]').on(data) + end + + def test_digits_only_string + data = { + 'foo' => { + 'type' => 'users', + 'id' => '123' + } + } + assert_equal([{ 'type' => 'users', 'id' => '123' }], JsonPath.new("$.foo[?(@.id == '123')]").on(data)) + end + + def test_digits_only_string_in_array + data = { + 'foo' => [{ + 'type' => 'users', + 'id' => '123' + }, { + 'type' => 'users', + 'id' => '321' + }] + } + assert_equal([{ 'type' => 'users', 'id' => '123' }], JsonPath.new("$.foo[?(@.id == '123')]").on(data)) + end + + def test_at_in_filter + jsonld = { + 'mentions' => [ + { + 'name' => 'Delimara Powerplant', + 'identifier' => 'krzana://took/powerstation/Delimara Powerplant', + '@type' => 'Place', + 'geo' => { + 'latitude' => 35.83020073454, + 'longitude' => 14.55602645874 + } + } + ] + } + assert_equal(['Place'], JsonPath.new("$..mentions[?(@['@type'] == 'Place')].@type").on(jsonld)) + end + + def test_dollar_in_filter + jsonld = { + 'mentions' => [ + { + 'name' => 'Delimara Powerplant', + 'identifier' => 'krzana://took/powerstation/Delimara Powerplant', + '$type' => 'Place', + 'geo' => { + 'latitude' => 35.83020073454, + 'longitude' => 14.55602645874 + } + } + ] + } + assert_equal(['Place'], JsonPath.new("$..mentions[?(@['$type'] == 'Place')].$type").on(jsonld)) + end + + def test_underscore_in_filter + jsonld = { + 'attributes' => [ + { + 'store' => [ + { 'with' => 'urn' }, + { 'with_underscore' => 'urn:1' } + ] + } + ] + } + assert_equal(['urn:1'], JsonPath.new("$.attributes..store[?(@['with_underscore'] == 'urn:1')].with_underscore").on(jsonld)) + end + + def test_at_in_value + jsonld = { + 'mentions' => + { + 'name' => 'Delimara Powerplant', + 'identifier' => 'krzana://took/powerstation/Delimara Powerplant', + 'type' => '@Place', + 'geo' => { + 'latitude' => 35.83020073454, + 'longitude' => 14.55602645874 + } + } + } + assert_equal(['@Place'], JsonPath.new("$..mentions.type[?(@ == '@Place')]").on(jsonld)) + end + + def test_parens_in_value + data = { + 'data' => { + 'number' => '(492) 080-3961' + } + } + assert_equal [{ 'number' => '(492) 080-3961' }], JsonPath.new("$.data[?(@.number == '(492) 080-3961')]").on(data) + end + + def test_boolean_parameter_value + data = { + 'data' => [{ + 'isTrue' => true, + 'name' => 'testname1' + }, { + 'isTrue' => false, + 'name' => 'testname2' + }] + } + + # These queries should be equivalent + expected = [{ 'isTrue' => true, 'name' => 'testname1' }] + assert_equal expected, JsonPath.new('$.data[?(@.isTrue)]').on(data) + assert_equal expected, JsonPath.new('$.data[?(@.isTrue==true)]').on(data) + assert_equal expected, JsonPath.new('$.data[?(@.isTrue == true)]').on(data) + + # These queries should be equivalent + expected = [{ 'isTrue' => false, 'name' => 'testname2' }] + assert_equal expected, JsonPath.new('$.data[?(@.isTrue != true)]').on(data) + assert_equal expected, JsonPath.new('$.data[?(@.isTrue!=true)]').on(data) + assert_equal expected, JsonPath.new('$.data[?(@.isTrue==false)]').on(data) + end + + def test_and_operator_with_boolean_parameter_value + data = { + 'data' => [{ + 'hasProperty1' => true, + 'hasProperty2' => false, + 'name' => 'testname1' + }, { + 'hasProperty1' => false, + 'hasProperty2' => true, + 'name' => 'testname2' + }, { + 'hasProperty1' => true, + 'hasProperty2' => true, + 'name' => 'testname3' + }] + } + assert_equal ['testname3'], JsonPath.new('$.data[?(@.hasProperty1 && @.hasProperty2)].name').on(data) + end + + def test_regex_simple + assert_equal %w[asdf asdf2], JsonPath.new('$.store.book..tags[?(@ =~ /asdf/)]').on(@object) + assert_equal %w[asdf asdf2], JsonPath.new('$.store.book..tags[?(@=~/asdf/)]').on(@object) + end + + def test_regex_simple_miss + assert_equal [], JsonPath.new('$.store.book..tags[?(@ =~ /wut/)]').on(@object) + end + + def test_regex_r + assert_equal %w[asdf asdf2], JsonPath.new('$.store.book..tags[?(@ =~ %r{asdf})]').on(@object) + end + + def test_regex_flags + assert_equal [ + @object['store']['book'][2], + @object['store']['book'][4], + @object['store']['book'][5], + @object['store']['book'][6] + ], JsonPath.new('$..book[?(@.author =~ /herman|lukyanenko/i)]').on(@object) + end + + def test_regex_error + assert_raises ArgumentError do + JsonPath.new('$.store.book..tags[?(@ =~ asdf)]').on(@object) + end + end + + def test_regression_1 + json = { + ok: true, + channels: [ + { + id: 'C09C5GYHF', + name: 'general' + }, + { + id: 'C09C598QL', + name: 'random' + } + ] + }.to_json + + assert_equal 'C09C5GYHF', JsonPath.on(json, "$..channels[?(@.name == 'general')].id")[0] + end + + def test_regression_2 + json = { + ok: true, + channels: [ + { + id: 'C09C5GYHF', + name: 'general', + is_archived: false + }, + { + id: 'C09C598QL', + name: 'random', + is_archived: true + } + ] + }.to_json + + assert_equal 'C09C5GYHF', JsonPath.on(json, '$..channels[?(@.is_archived == false)].id')[0] + end + + def test_regression_3 + json = { + ok: true, + channels: [ + { + id: 'C09C5GYHF', + name: 'general', + is_archived: false + }, + { + id: 'C09C598QL', + name: 'random', + is_archived: true + } + ] + }.to_json + + assert_equal 'C09C598QL', JsonPath.on(json, '$..channels[?(@.is_archived)].id')[0] + end + + def test_regression_4 + json = { + ok: true, + channels: [ + { + id: 'C09C5GYHF', + name: 'general', + is_archived: false }, - { "category"=> "fiction", - "author"=> "Evelyn Waugh", - "title"=> "Sword of Honour", - "price"=> 13 + { + id: 'C09C598QL', + name: 'random', + is_archived: true + } + ] + }.to_json + + assert_equal ['C09C5GYHF'], JsonPath.on(json, "$..channels[?(@.name == 'general')].id") + end + + def test_regression_5 + json = { + ok: true, + channels: [ + { + id: 'C09C5GYHF', + name: 'general', + is_archived: 'false' }, - { "category"=> "fiction", - "author"=> "Herman Melville", - "title"=> "Moby Dick", - "isbn"=> "0-553-21311-3", - "price"=> 9 + { + id: 'C09C598QL', + name: 'random', + is_archived: true + } + ] + }.to_json + + assert_equal 'C09C5GYHF', JsonPath.on(json, "$..channels[?(@.is_archived == 'false')].id")[0] + end + + def test_quote + json = { + channels: [ + { + name: "King's Speech" + } + ] + }.to_json + + assert_equal [{ 'name' => "King\'s Speech" }], JsonPath.on(json, "$..channels[?(@.name == 'King\'s Speech')]") + end + + def test_curly_brackets + data = { + '{data}' => 'data' + } + assert_equal ['data'], JsonPath.new('$.{data}').on(data) + end + + def test_symbolize + data = ' + { + "store": { + "bicycle": { + "price": 19.95, + "color": "red" }, - { "category"=> "fiction", - "author"=> "J. R. R. Tolkien", - "title"=> "The Lord of the Rings", - "isbn"=> "0-395-19395-8", - "price"=> 23 + "book": [ + { + "price": 8.95, + "category": "reference", + "title": "Sayings of the Century", + "author": "Nigel Rees" + }, + { + "price": 12.99, + "category": "fiction", + "title": "Sword of Honour", + "author": "Evelyn Waugh" + }, + { + "price": 8.99, + "category": "fiction", + "isbn": "0-553-21311-3", + "title": "Moby Dick", + "author": "Herman Melville", + "color": "blue" + }, + { + "price": 22.99, + "category": "fiction", + "isbn": "0-395-19395-8", + "title": "The Lord of the Rings", + "author": "Tolkien" + } + ] + } + } + ' + assert_equal [{ price: 8.95, category: 'reference', title: 'Sayings of the Century', author: 'Nigel Rees' }, { price: 8.99, category: 'fiction', isbn: '0-553-21311-3', title: 'Moby Dick', author: 'Herman Melville', color: 'blue' }], JsonPath.new('$..book[::2]').on(data, symbolize_keys: true) + end + + def test_changed + json = + { + 'snapshot' => { + 'objects' => { + 'whatever' => [ + { + 'column' => { + 'name' => 'ASSOCIATE_FLAG', + 'nullable' => true + } + }, + { + 'column' => { + 'name' => 'AUTHOR', + 'nullable' => false + } + } + ] + } + } + } + assert_equal true, JsonPath.on(json, "$..column[?(@.name == 'ASSOCIATE_FLAG')].nullable")[0] + end + + def test_another + json = { + initial: true, + not: true + }.to_json + assert_equal [{ 'initial' => true, 'not' => true }], JsonPath.on(json, '$.[?(@.initial == true)]') + json = { + initial: false, + not: true + }.to_json + assert_equal [], JsonPath.on(json, '$.initial[?(@)]') + assert_equal [], JsonPath.on(json, '$.[?(@.initial == true)]') + assert_equal [{ 'initial' => false, 'not' => true }], JsonPath.on(json, '$.[?(@.initial == false)]') + json = { + initial: 'false', + not: true + }.to_json + assert_equal [{ 'initial' => 'false', 'not' => true }], JsonPath.on(json, "$.[?(@.initial == 'false')]") + assert_equal [], JsonPath.on(json, '$.[?(@.initial == false)]') + end + + def test_hanging + json = { initial: true }.to_json + success_path = '$.initial' + assert_equal [true], JsonPath.on(json, success_path) + broken_path = "$.initial\n" + assert_equal [true], JsonPath.on(json, broken_path) + end + + def test_complex_nested_grouping + path = "$..book[?((@['author'] == 'Evelyn Waugh' || @['author'] == 'Herman Melville') && (@['price'] == 33 || @['price'] == 9))]" + assert_equal [@object['store']['book'][2]], JsonPath.new(path).on(@object) + end + + def test_nested_with_unknown_key + path = "$..[?(@.price == 9 || @.price == 33)].title" + assert_equal ["Sayings of the Century", "Moby Dick", "Sayings of the Century", "Moby Dick"], JsonPath.new(path).on(@object) + end + + def test_nested_with_unknown_key_filtered_array + path = "$..[?(@['price'] == 9 || @['price'] == 33)].title" + assert_equal ["Sayings of the Century", "Moby Dick", "Sayings of the Century", "Moby Dick"], JsonPath.new(path).on(@object) + end + + def test_runtime_error_frozen_string + skip('in ruby version below 2.2.0 this error is not raised') if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.2.0') || Gem::Version.new(RUBY_VERSION) > Gem::Version::new('2.6') + json = ' + { + "test": "something" + } + '.to_json + assert_raises(ArgumentError, "RuntimeError: character '|' not supported in query") do + JsonPath.on(json, '$.description|title') + end + end + + def test_delete_more_items + a = { 'itemList' => + [{ 'alfa' => 'beta1' }, + { 'alfa' => 'beta2' }, + { 'alfa' => 'beta3' }, + { 'alfa' => 'beta4' }, + { 'alfa' => 'beta5' }, + { 'alfa' => 'beta6' }, + { 'alfa' => 'beta7' }, + { 'alfa' => 'beta8' }, + { 'alfa' => 'beta9' }, + { 'alfa' => 'beta10' }, + { 'alfa' => 'beta11' }, + { 'alfa' => 'beta12' }] } + expected = { 'itemList' => [{ 'alfa' => 'beta1' }] } + assert_equal expected, JsonPath.for(a.to_json).delete('$.itemList[1:12:1]').to_hash + end + + def test_delete_more_items_with_stepping + a = { 'itemList' => + [{ 'alfa' => 'beta1' }, + { 'alfa' => 'beta2' }, + { 'alfa' => 'beta3' }, + { 'alfa' => 'beta4' }, + { 'alfa' => 'beta5' }, + { 'alfa' => 'beta6' }, + { 'alfa' => 'beta7' }, + { 'alfa' => 'beta8' }, + { 'alfa' => 'beta9' }, + { 'alfa' => 'beta10' }, + { 'alfa' => 'beta11' }, + { 'alfa' => 'beta12' }] } + expected = { 'itemList' => + [{ 'alfa' => 'beta1' }, + { 'alfa' => 'beta3' }, + { 'alfa' => 'beta5' }, + { 'alfa' => 'beta7' }, + { 'alfa' => 'beta8' }, + { 'alfa' => 'beta9' }, + { 'alfa' => 'beta10' }, + { 'alfa' => 'beta11' }, + { 'alfa' => 'beta12' }] } + assert_equal expected, JsonPath.for(a.to_json).delete('$.itemList[1:6:2]').to_hash + end + + def test_nested_values + json = ' + { + "phoneNumbers": [ + [{ + "type" : "iPhone", + "number": "0123-4567-8888" + }], + [{ + "type" : "home", + "number": "0123-4567-8910" + }] + ] + } + '.to_json + assert_equal [[{ 'type' => 'home', 'number' => '0123-4567-8910' }]], JsonPath.on(json, "$.phoneNumbers[?(@[0].type == 'home')]") + assert_equal [], JsonPath.on(json, "$.phoneNumbers[?(@[2].type == 'home')]") + json = ' + { + "phoneNumbers": + { + "type" : "iPhone", + "number": "0123-4567-8888" + } + } + '.to_json + assert_equal [], JsonPath.on(json, "$.phoneNumbers[?(@[0].type == 'home')]") + end + + def test_selecting_multiple_keys_on_hash + json = ' + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + } + '.to_json + assert_equal [{ 'category' => 'reference', 'author' => 'Nigel Rees' }], JsonPath.on(json, '$.(category,author)') + end + + def test_selecting_multiple_keys_on_sub_hash + skip("Failing as the semantics of .(x,y) is unclear") + json = ' + { + "book": { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + } + } + '.to_json + assert_equal [{ 'category' => 'reference', 'author' => 'Nigel Rees' }], JsonPath.on(json, '$.book.(category,author)') + end + + def test_selecting_multiple_keys_on_array + json = ' + { + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + } + ] + } + } + '.to_json + + assert_equal [{ 'category' => 'reference', 'author' => 'Nigel Rees' }, { 'category' => 'fiction', 'author' => 'Evelyn Waugh' }], JsonPath.on(json, '$.store.book[*](category,author)') + end + + def test_selecting_multiple_keys_on_array_with_filter + json = ' + { + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + } + ] + } + } + '.to_json + + assert_equal [{ 'category' => 'reference', 'author' => 'Nigel Rees' }], JsonPath.on(json, "$.store.book[?(@['price'] == 8.95)](category,author)") + assert_equal [{ 'category' => 'reference', 'author' => 'Nigel Rees' }], JsonPath.on(json, "$.store.book[?(@['price'] == 8.95)]( category, author )") + end + + def test_selecting_multiple_keys_with_filter_with_space_in_catergory + json = ' + { + "store": { + "book": [ + { + "cate gory": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "cate gory": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + } + ] + } + } + '.to_json + + assert_equal [{ 'cate gory' => 'reference', 'author' => 'Nigel Rees' }], JsonPath.on(json, "$.store.book[?(@['price'] == 8.95)]( cate gory, author )") + end + + def test_use_symbol_opt + json = { + store: { + book: [ + { + category: "reference", + author: "Nigel Rees", + title: "Sayings of the Century", + price: 8.95 + }, + { + category: "fiction", + author: "Evelyn Waugh", + title: "Sword of Honour", + price: 12.99 + } + ] + } + } + on = ->(path){ JsonPath.on(json, path, use_symbols: true) } + assert_equal ['reference', 'fiction'], on.("$.store.book[*].category") + assert_equal ['reference', 'fiction'], on.("$..category") + assert_equal ['reference'], on.("$.store.book[?(@['price'] == 8.95)].category") + assert_equal [{'category' => 'reference'}], on.("$.store.book[?(@['price'] == 8.95)](category)") + end + + def test_object_method_send + j = {height: 5, hash: "some_hash"}.to_json + hs = JsonPath.new "$..send" + assert_equal([], hs.on(j)) + hs = JsonPath.new "$..hash" + assert_equal(["some_hash"], hs.on(j)) + hs = JsonPath.new "$..send" + assert_equal([], hs.on(j)) + j = {height: 5, send: "should_still_work"}.to_json + hs = JsonPath.new "$..send" + assert_equal(['should_still_work'], hs.on(j)) + end + + def test_index_access_by_number + data = { + '1': 'foo' + } + assert_equal ['foo'], JsonPath.new('$.1').on(data.to_json) + end + + def test_behavior_on_null_and_missing + data = { + "foo" => nil, + "bar" => { + "baz" => nil + }, + "bars" => [ + { "foo" => 12 }, + { "foo" => nil }, + { } + ] + } + assert_equal [nil], JsonPath.new('$.foo').on(data) + assert_equal [nil], JsonPath.new('$.bar.baz').on(data) + assert_equal [], JsonPath.new('$.baz').on(data) + assert_equal [], JsonPath.new('$.bar.foo').on(data) + assert_equal [12, nil], JsonPath.new('$.bars[*].foo').on(data) + end + + def test_default_path_leaf_to_null_opt + data = { + "foo" => nil, + "bar" => { + "baz" => nil + }, + "bars" => [ + { "foo" => 12 }, + { "foo" => nil }, + { } + ] + } + assert_equal [nil], JsonPath.new('$.foo', default_path_leaf_to_null: true).on(data) + assert_equal [nil], JsonPath.new('$.bar.baz', default_path_leaf_to_null: true).on(data) + assert_equal [nil], JsonPath.new('$.baz', default_path_leaf_to_null: true).on(data) + assert_equal [nil], JsonPath.new('$.bar.foo', default_path_leaf_to_null: true).on(data) + assert_equal [12, nil, nil], JsonPath.new('$.bars[*].foo', default_path_leaf_to_null: true).on(data) + end + + def test_raise_max_nesting_error + json = { + a: { + b: { + c: { + } + } + } + }.to_json + + assert_raises(MultiJson::ParseError) { JsonPath.new('$.a', max_nesting: 1).on(json) } + end + + def test_linefeed_in_path_error + assert_raises(ArgumentError) { JsonPath.new("$.store\n.book") } + end + + def test_with_max_nesting_false + json = { + a: { + b: { + c: { + } + } + } + }.to_json + + assert_equal [{}], JsonPath.new('$.a.b.c', max_nesting: false).on(json) + end + + def test_initialize_with_max_nesting_exceeding_limit + json = { + a: { + b: { + c: { + } } + } + }.to_json + + json_obj = JsonPath.new('$.a.b.c', max_nesting: 105) + assert_equal [{}], json_obj.on(json) + assert_equal false, json_obj.instance_variable_get(:@opts)[:max_nesting] + end + + def test_initialize_without_max_nesting_exceeding_limit + json_obj = JsonPath.new('$.a.b.c', max_nesting: 90) + assert_equal 90, json_obj.instance_variable_get(:@opts)[:max_nesting] + end + + def test_initialize_with_max_nesting_false_limit + json_obj = JsonPath.new('$.a.b.c', max_nesting: false) + assert_equal false, json_obj.instance_variable_get(:@opts)[:max_nesting] + end + + def example_object + { 'store' => { + 'book' => [ + { 'category' => 'reference', + 'author' => 'Nigel Rees', + 'title' => 'Sayings of the Century', + 'price' => 9, + 'tags' => %w[asdf asdf2] }, + { 'category' => 'fiction', + 'author' => 'Evelyn Waugh', + 'title' => 'Sword of Honour', + 'price' => 13 }, + { 'category' => 'fiction', + 'author' => 'Herman Melville', + 'title' => 'Moby Dick', + 'isbn' => '0-553-21311-3', + 'price' => 9 }, + { 'category' => 'fiction', + 'author' => 'J. R. R. Tolkien', + 'title' => 'The Lord of the Rings', + 'isbn' => '0-395-19395-8', + 'price' => 23 }, + { 'category' => 'russian_fiction', + 'author' => 'Lukyanenko', + 'title' => 'Imperatory Illuziy', + 'written' => { + 'year' => 1995 + } }, + { 'category' => 'russian_fiction', + 'author' => 'Lukyanenko', + 'title' => 'Osennie Vizity', + 'written' => { + 'year' => 1996 + } }, + { 'category' => 'russian_fiction', + 'author' => 'Lukyanenko', + 'title' => 'Ne vremya dlya drakonov', + 'written' => { + 'year' => 1997 + } } ], - "bicycle"=> { - "color"=> "red", - "price"=> 20, - "catalogue_number" => 12345, - "single-speed" => "no", - "2seater" => "yes"} + 'bicycle' => { + 'color' => 'red', + 'price' => 20, + 'catalogue_number' => 123_45, + 'single-speed' => 'no', + '2seater' => 'yes', + 'make:model' => 'Zippy Sweetwheeler' + }, + '@id' => 'http://example.org/store/42', + '$meta-data' => 'whatevs', + 'Übermorgen' => 'The day after tomorrow', + 'Title Case' => 'A title case string', + '_links' => { 'self' => {} } } } end + def test_fetch_all_path + data = { + "foo" => nil, + "bar" => { + "baz" => nil + }, + "bars" => [ + { "foo" => 12 }, + { "foo" => nil }, + { } + ] + } + assert_equal ["$", "$.foo", "$.bar", "$.bar.baz", "$.bars", "$.bars[0].foo", "$.bars[0]", "$.bars[1].foo", "$.bars[1]", "$.bars[2]"], JsonPath.fetch_all_path(data) + end + + + def test_extractore_with_dollar_key + json = {"test" => {"$" =>"success", "a" => "123"}} + assert_equal ["success"], JsonPath.on(json, "$.test.$") + assert_equal ["123"], JsonPath.on(json, "$.test.a") + end + + def test_symbolize_key + data = { "store" => { "book" => [{"category" => "reference"}]}} + assert_equal [{"category": "reference"}], JsonPath.new('$..book[0]', symbolize_keys: true).on(data) + assert_equal [{"category": "reference"}], JsonPath.new('$..book[0]').on(data, symbolize_keys: true) + end end diff --git a/test/test_jsonpath_bin.rb b/test/test_jsonpath_bin.rb index 626a1197..b812661a 100644 --- a/test/test_jsonpath_bin.rb +++ b/test/test_jsonpath_bin.rb @@ -1,6 +1,12 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'phocus' +require 'jsonpath' + class TestJsonpathBin < MiniTest::Unit::TestCase def setup - @runner = "ruby -Ilib bin/jsonpath" + @runner = 'ruby -Ilib bin/jsonpath' @original_dir = Dir.pwd Dir.chdir(File.join(File.dirname(__FILE__), '..')) end @@ -11,11 +17,7 @@ def teardown end def test_stdin - assert_equal '["time"]', `echo '{"test": "time"}' | #{@runner} '$.test'`.strip - end - - def test_stdin - File.open('/tmp/test.json', 'w'){|f| f << '{"test": "time"}'} + File.open('/tmp/test.json', 'w') { |f| f << '{"test": "time"}' } assert_equal '["time"]', `#{@runner} '$.test' /tmp/test.json`.strip end -end \ No newline at end of file +end diff --git a/test/test_readme.rb b/test/test_readme.rb new file mode 100644 index 00000000..ab5deb48 --- /dev/null +++ b/test/test_readme.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'phocus' +require 'jsonpath' +require 'json' + +class TestJsonpathReadme < MiniTest::Unit::TestCase + + def setup + @json = <<-HERE_DOC + {"store": + {"bicycle": + {"price":19.95, "color":"red"}, + "book":[ + {"price":8.95, "category":"reference", "title":"Sayings of the Century", "author":"Nigel Rees"}, + {"price":12.99, "category":"fiction", "title":"Sword of Honour", "author":"Evelyn Waugh"}, + {"price":8.99, "category":"fiction", "isbn":"0-553-21311-3", "title":"Moby Dick", "author":"Herman Melville","color":"blue"}, + {"price":22.99, "category":"fiction", "isbn":"0-395-19395-8", "title":"The Lord of the Rings", "author":"Tolkien"} + ] + } + } + HERE_DOC + end + attr_reader :json + + def test_library_section + path = JsonPath.new('$..price') + assert_equal [19.95, 8.95, 12.99, 8.99, 22.99], path.on(json) + assert_equal [18.88], path.on('{"books":[{"title":"A Tale of Two Somethings","price":18.88}]}') + assert_equal ["Nigel Rees", "Evelyn Waugh", "Herman Melville", "Tolkien"], JsonPath.on(json, '$..author') + assert_equal [ + {"price" => 8.95, "category" => "reference", "title" => "Sayings of the Century", "author" => "Nigel Rees"}, + {"price" => 8.99, "category" => "fiction", "isbn" => "0-553-21311-3", "title" => "Moby Dick", "author" => "Herman Melville","color" => "blue"}, + ], JsonPath.new('$..book[::2]').on(json) + assert_equal [8.95, 8.99], JsonPath.new("$..price[?(@ < 10)]").on(json) + assert_equal ["Sayings of the Century", "Moby Dick"], JsonPath.new("$..book[?(@['price'] == 8.95 || @['price'] == 8.99)].title").on(json) + assert_equal [], JsonPath.new("$..book[?(@['price'] == 8.95 && @['price'] == 8.99)].title").on(json) + assert_equal "red", JsonPath.new('$..color').first(json) + end + + def test_library_section_enumerable + enum = JsonPath.new('$..color')[json] + assert_equal "red", enum.first + assert enum.any?{ |c| c == 'red' } + end + + def test_ruby_structures_section + book = { title: "Sayings of the Century" } + assert_equal [], JsonPath.new('$.title').on(book) + assert_equal ["Sayings of the Century"], JsonPath.new('$.title', use_symbols: true).on(book) + + book_class = Struct.new(:title) + book = book_class.new("Sayings of the Century") + assert_equal ["Sayings of the Century"], JsonPath.new('$.title').on(book) + + book_class = Class.new{ attr_accessor :title } + book = book_class.new + book.title = "Sayings of the Century" + assert_equal ["Sayings of the Century"], JsonPath.new('$.title', allow_send: true).on(book) + end + + def test_options_section + assert_equal ["0-553-21311-3", "0-395-19395-8"], JsonPath.new('$..book[*].isbn').on(json) + assert_equal [nil, nil, "0-553-21311-3", "0-395-19395-8"], JsonPath.new('$..book[*].isbn', default_path_leaf_to_null: true).on(json) + + assert_equal ["price", "category", "title", "author"], JsonPath.new('$..book[0]').on(json).map(&:keys).flatten.uniq + assert_equal [:price, :category, :title, :author], JsonPath.new('$..book[0]').on(json, symbolize_keys: true).map(&:keys).flatten.uniq + end + + def selecting_value_section + json = <<-HERE_DOC + { + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + } + ] + } + HERE_DOC + got = JsonPath.on(json, "$.store.book[*](category,author)") + expected = [ + { + "category" => "reference", + "author" => "Nigel Rees" + }, + { + "category" => "fiction", + "author" => "Evelyn Waugh" + } + ] + assert_equal expected, got + end + + def test_manipulation_section + assert_equal({"candy" => "big turks"}, JsonPath.for('{"candy":"lollipop"}').gsub('$..candy') {|v| "big turks" }.to_hash) + + json = '{"candy":"lollipop","noncandy":null,"other":"things"}' + o = JsonPath.for(json). + gsub('$..candy') {|v| "big turks" }. + compact. + delete('$..other'). + to_hash + assert_equal({"candy" => "big turks"}, o) + end + +end