├── .gitignore ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.markdown ├── COPYING ├── Gemfile ├── README.markdown ├── Rakefile ├── bin └── jgrep ├── jgrep.gemspec ├── lib ├── jgrep.rb └── parser │ ├── parser.rb │ └── scanner.rb └── spec ├── Rakefile ├── spec_helper.rb └── unit ├── jgrep_spec.rb ├── parser_spec.rb └── scanner_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .ruby-* 2 | Gemfile.lock 3 | tags* 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisplayCopNames: true 3 | DisplayStyleGuide: true 4 | 5 | Style/NumericLiterals: 6 | Enabled: false 7 | 8 | Style/StringLiterals: 9 | EnforcedStyle: double_quotes 10 | 11 | Style/FormatString: 12 | EnforcedStyle: percent 13 | 14 | Style/SpecialGlobalVars: 15 | Enabled: false 16 | 17 | Style/PerlBackrefs: 18 | Enabled: false 19 | 20 | Metrics/AbcSize: 21 | Enabled: false 22 | 23 | Metrics/PerceivedComplexity: 24 | Enabled: false 25 | 26 | Metrics/CyclomaticComplexity: 27 | Enabled: false 28 | 29 | Metrics/LineLength: 30 | Max: 200 31 | 32 | Performance/RegexpMatch: 33 | Enabled: false 34 | 35 | Metrics/MethodLength: 36 | Max: 100 37 | 38 | Metrics/BlockLength: 39 | Enabled: false 40 | 41 | Style/ConditionalAssignment: 42 | Enabled: false 43 | 44 | Style/PredicateName: 45 | Enabled: false 46 | 47 | Style/RegexpLiteral: 48 | Enabled: false 49 | 50 | Style/SafeNavigation: 51 | Enabled: false 52 | 53 | Layout/SpaceInsideBlockBraces: 54 | Enabled: false 55 | 56 | Layout/SpaceInsideHashLiteralBraces: 57 | Enabled: false 58 | 59 | Style/Documentation: 60 | Enabled: false 61 | 62 | Metrics/ModuleLength: 63 | Enabled: false 64 | 65 | Style/FrozenStringLiteralComment: 66 | Enabled: false 67 | 68 | Style/NumericPredicate: 69 | Enabled: false 70 | 71 | Metrics/BlockNesting: 72 | Max: 4 73 | 74 | Style/AccessorMethodName: 75 | Enabled: false 76 | 77 | Metrics/ClassLength: 78 | Enabled: false 79 | 80 | Lint/Loop: 81 | Enabled: false 82 | 83 | Lint/AssignmentInCondition: 84 | Enabled: false 85 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.0.0 5 | - 2.1.9 6 | - 2.4.0 7 | - 2.7.0 8 | -------------------------------------------------------------------------------- /CHANGELOG.markdown: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.5.4 4 | * Include missing fixes for Ruby 2.7.0 deprecations 5 | 6 | ## 1.5.3 7 | * Fix Ruby 2.7.0 deprecation warnings 8 | * Bump Rspec version in Gemfile 9 | 10 | ## 1.5.2 11 | * Fixed an issue where strings like 2012R2 would get parsed as floats 12 | 13 | ## 1.5.1 14 | * Now handles escaped parens when tokenising statements 15 | 16 | ## 1.5.0 17 | * Dropped support for Ruby 1.8.3 18 | * Added support for modern Ruby versions (Tested up to 2.4.0) 19 | * Added utility method to validate expressions 20 | 21 | ## 1.4.1 22 | * Fix binary exit code to be 1 when no matches are found (Mickaël Canévet) 23 | 24 | ## 1.4.0 25 | * Expressions support matching true/false booleans (Boyan Tabakov) 26 | * `--slice` option added to jgrep to get array elements (Jon McKenzie) 27 | * `-i` option to read file supported without a TTY (Jon McKenzie) 28 | * `-n` streaming option from 1.3.2 reinstated 29 | * Performance fix: string splitting replaced with character access 30 | * Performance fix: regexes replaced with simpler string methods 31 | * Tests fixed and enabled on Travis CI 32 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2011 P.Loubser 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem "mocha" 6 | gem "rake", ">= 12.3.3" 7 | gem "rspec" 8 | gem "rubocop", "~> 0.49.1" 9 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | JGrep is a command line tool and API for parsing JSON documents based on logical expressions. 2 | 3 | ### Installation: 4 | 5 | jgrep is available as a gem: 6 | 7 | gem install jgrep 8 | 9 | ### JGrep binary usage: 10 | 11 | jgrep [expression] -i foo.json 12 | 13 | or 14 | 15 | cat "foo.json" | jgrep [expression] 16 | 17 | ### Flags: 18 | 19 | -s, --simple [FIELDS] : Greps the JSON and only returns the value of the field(s) specified 20 | -c, --compat : Returns the JSON in its non-pretty flat form 21 | -n, --stream : Specify continuous input 22 | -f, --flatten : Flatten the results as much as possible 23 | -i, --input [FILENAME] : Target JSON file to use as input 24 | -q, --quiet : Quiet; don't write to stdout. Exit with zero status if match found. 25 | -v, --verbose : Verbose output that will list a document if it fails to parse 26 | --start FIELD : Starts the grep at a specific key in the document 27 | --slice [RANGE] : A range of the form 'n' or 'n..m', indicating which documents to extract from the final output 28 | 29 | ### Expressions: 30 | 31 | JGrep uses the following logical symbols to define expressions. 32 | 33 | 'and' : 34 | - [statement] and [statement] 35 | 36 | Evaluates to true if both statements are true 37 | 38 | 'or' : 39 | - [statement] or [statement] 40 | 41 | Evaluates true if either statement is true 42 | 43 | 'not' : 44 | - ! [statement] 45 | - not [statement] 46 | 47 | Inverts the value of statement 48 | 49 | '+' 50 | - +[value] 51 | 52 | Returns true if value is present in the json document 53 | 54 | '-' 55 | - -[value] 56 | 57 | Returns true if value is not present in the json doument 58 | 59 | '(' and ')' 60 | 61 | - (expression1) and expression2 62 | 63 | Performs the operations inside the perentheses first. 64 | 65 | ### Statements: 66 | 67 | A statement is defined as some value in a json document compared to another value. 68 | Available comparison operators are '=', '<', '>', '<=', '>=' 69 | 70 | Examples: 71 | 72 | foo.bar=1 73 | foo.bar>0 74 | foo.bar<=1.3 75 | 76 | ### Complex expressions: 77 | 78 | Given a json document, {"foo":1, "bar":null}, the following are examples of valid expressions 79 | 80 | Examples: 81 | 82 | +foo 83 | 84 | ... returns true 85 | 86 | -bar 87 | 88 | ... returns false 89 | 90 | +foo and !(foo=2) 91 | 92 | ... returns true 93 | 94 | !(foo>=2 and bar=null) or !(bar=null) 95 | 96 | ... returns true 97 | 98 | ### CLI missing an expression: 99 | 100 | If JGrep is executed without a set expression, it will return an unmodified JSON document. The 101 | -s flag can still be applied to the result. 102 | 103 | ### In document comparison: 104 | 105 | If a document contains an array, the '[' and ']' operators can be used to define a comparison where 106 | statements are checked for truth on a per element basis which will then be combined. 107 | 108 | Example: 109 | 110 | [foo.bar1=1 and foo.bar2=2] 111 | 112 | on 113 | 114 | [ 115 | { 116 | "foo": [ 117 | { 118 | "bar1":1 119 | }, 120 | { 121 | "bar2":2 122 | } 123 | ] 124 | }, 125 | { 126 | "foo": [ 127 | { 128 | "bar1":0 129 | }, 130 | { 131 | "bar2":0 132 | } 133 | ] 134 | } 135 | ] 136 | 137 | will return 138 | 139 | [ 140 | { 141 | "foo": [ 142 | { 143 | "bar1": 1 144 | }, 145 | { 146 | "bar2": 2 147 | } 148 | ] 149 | } 150 | ] 151 | 152 | 153 | **Note**: In document comparison cannot be nested. 154 | 155 | ### The -s flag: 156 | 157 | The s flag simplifies the output returned by JGrep. Given a JSON document 158 | 159 | [{"a":1, "b":2, "c":3}, {"a":3, "b":2, "c":1}] 160 | 161 | a JGrep invocation like 162 | 163 | cat my.json | jgrep "a=1" -s b 164 | 165 | will output 166 | 167 | 1 168 | 169 | The s flag can also be used with multiple field, which will return JSON as output which only contain the specified fields. 170 | **Note**: Separate fields by a space and enclose all fields in quotes (see example below) 171 | 172 | Given: 173 | 174 | [{"a":1, "b":2, "c":3}, {"a":3, "b":2, "c":1}] 175 | 176 | a JGrep invocation like 177 | 178 | cat my.json | jgrep "a>0" -s "a c" 179 | 180 | will output 181 | 182 | [ 183 | { 184 | "a" : 1, 185 | "c" : 3 186 | }, 187 | { 188 | "a" : 3, 189 | "c" : 1 190 | } 191 | ] 192 | 193 | ### The --start flag: 194 | 195 | Some documents do not comply to our expected format, they might have an array embedded deep in a field. The --start 196 | flag lets you pick a starting point for the grep. 197 | 198 | An example document can be seen here: 199 | 200 | {"results": [ 201 | {"name":"Jack", "surname":"Smith"}, 202 | {"name":"Jill", "surname":"Jones"} 203 | ] 204 | } 205 | 206 | This document does not comply to our standard but does contain data that can be searched - the _results_ field. 207 | We can use the --start flat to tell jgrep to start looking for data in that field: 208 | 209 |
210 | $ cat my.json | jgrep --start results name=Jack -s surname
211 | Smith
212 | 
213 | 214 | ### The --slice flag 215 | 216 | Allows the user to provide an int or range to slice an array of 217 | results with, in particular so a single element can be extracted, e.g. 218 | 219 | $ echo '[{"foo": {"bar": "baz"}}, {"foo": {"bar":"baz"}}]' | 220 | jgrep "foo.bar=baz" --slice 0 221 | { 222 | "foo": { 223 | "bar": "baz" 224 | } 225 | } 226 | 227 | ### The --stream flag 228 | 229 | With the --stream or -n flag, jgrep will process multiple JSON inputs (newline 230 | separated) until standard input is closed. Each JSON input will be processed 231 | as usual, but the output immediately printed. 232 | 233 | ### JGrep Gem usage: 234 | 235 | require 'jgrep' 236 | 237 | json = File.read("yourfile.json") 238 | expression = "foo=1 or bar=1" 239 | 240 | JGrep::jgrep(json, expression) 241 | 242 | sflags = "foo" 243 | 244 | JGrep::jgrep(json, expression, sflags) 245 | 246 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | RSpec::Core::RakeTask.new(:spec) 4 | 5 | desc "Run rubycop style checks" 6 | task :rubocop do 7 | sh("rubocop -f progress -f offenses lib spec bin") 8 | end 9 | 10 | task :default => [:rubocop, :spec] 11 | -------------------------------------------------------------------------------- /bin/jgrep: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "jgrep" 4 | require "optparse" 5 | 6 | @options = {flat: false, start: nil, field: [], slice: nil} 7 | 8 | def print_json(result) 9 | if @options[:flat] 10 | puts(result.first.to_json) 11 | else 12 | result = result.first if @options[:stream] 13 | puts(JSON.pretty_generate(result)) 14 | end 15 | end 16 | 17 | def do_grep(json, expression) 18 | if @options[:field].empty? 19 | result = JGrep.jgrep(json, expression, nil, @options[:start]) 20 | result = result.slice(@options[:slice]) if @options[:slice] 21 | 22 | exit 1 if result == [] 23 | 24 | print_json(result) unless @options[:quiet] == true 25 | elsif @options[:field].size > 1 26 | JGrep.validate_filters(@options[:field]) 27 | result = JGrep.jgrep(json, expression, @options[:field], @options[:start]) 28 | result = result.slice(@options[:slice]) if @options[:slice] 29 | 30 | exit 1 if result == [] 31 | 32 | print_json(result) unless @options[:quiet] == true 33 | 34 | else 35 | JGrep.validate_filters(@options[:field][0]) 36 | result = JGrep.jgrep(json, expression, @options[:field][0], @options[:start]) 37 | result = result.slice(@options[:slice]) if @options[:slice] 38 | exit 1 if result == [] 39 | if result.is_a?(Array) && !(result.first.is_a?(Hash) || result.flatten.first.is_a?(Hash)) 40 | unless @options[:quiet] == true 41 | result.map {|x| puts x unless x.nil?} 42 | end 43 | else 44 | print_json(result) unless @options[:quiet] == true 45 | end 46 | end 47 | end 48 | 49 | begin 50 | OptionParser.new do |opts| 51 | opts.banner = "Usage: jgrep [options] \"expression\"" 52 | opts.on("-s", "--simple [FIELDS]", "Display only one or more fields from each of the resulting json documents") do |field| 53 | raise "-s flag requires a field value" if field.nil? 54 | 55 | @options[:field].concat(field.split(" ")) 56 | end 57 | 58 | opts.on("-c", "--compact", "Display non pretty json") do 59 | @options[:flat] = true 60 | end 61 | 62 | opts.on("-n", "--stream", "Display continuous output from continuous input") do 63 | @options[:stream] = true 64 | end 65 | 66 | opts.on("-f", "--flatten", "Makes output as flat as possible") do 67 | JGrep.flatten_on 68 | end 69 | 70 | opts.on("-i", "--input [FILENAME]", "Specify input file to parse") do |filename| 71 | @options[:file] = filename 72 | end 73 | 74 | opts.on("-q", "--quiet", "Quiet; don't write to stdout. Exit with zero status if match found.") do 75 | @options[:quiet] = true 76 | end 77 | 78 | opts.on("-v", "--verbose", "Verbose output") do 79 | JGrep.verbose_on 80 | end 81 | 82 | opts.on("--start [FIELD]", "Where in the data to start from") do |field| 83 | @options[:start] = field 84 | end 85 | 86 | opts.on("--slice [RANGE]", "A range of the form 'n' or 'n..m', indicating which documents to extract from the final output") do |field| 87 | range_nums = field.split("..").map(&:to_i) 88 | @options[:slice] = range_nums.length == 1 ? range_nums[0] : Range.new(*range_nums) 89 | end 90 | end.parse! 91 | rescue OptionParser::InvalidOption => e 92 | puts e.to_s.capitalize 93 | exit 1 94 | rescue Exception => e # rubocop:disable Lint/RescueException 95 | puts e 96 | exit 1 97 | end 98 | 99 | begin 100 | expression = nil 101 | 102 | # Identify the expression from command line arguments 103 | ARGV.each do |argument| 104 | if argument =~ /<|>|=|\+|-/ 105 | expression = argument 106 | ARGV.delete(argument) 107 | end 108 | end 109 | 110 | expression = "" if expression.nil? 111 | 112 | # Continuously gets if inputstream in constant 113 | # Load json from standard input if tty is false 114 | # else find and load file from command line arugments 115 | 116 | if @options[:stream] 117 | raise "No json input specified" if STDIN.tty? 118 | 119 | while json = gets 120 | do_grep(json, expression) 121 | end 122 | elsif @options[:file] 123 | json = File.read(@options[:file]) 124 | do_grep(json, expression) 125 | elsif !STDIN.tty? 126 | json = STDIN.read 127 | do_grep(json, expression) 128 | else 129 | raise "No json input specified" 130 | end 131 | rescue Interrupt 132 | STDERR.puts "Exiting..." 133 | exit 1 134 | rescue SystemExit 135 | exit e.status 136 | rescue Exception => e # rubocop:disable Lint/RescueException 137 | STDERR.puts "Error - #{e}" 138 | exit 1 139 | end 140 | -------------------------------------------------------------------------------- /jgrep.gemspec: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "jgrep" 5 | 6 | s.version = "1.5.4" 7 | s.date = Date.today.to_s 8 | 9 | s.summary = "Filter JSON documents with a simple logical language" 10 | s.description = "Compare a list of json documents to a simple logical language and returns matches as output" 11 | s.homepage = "https://github.com/ploubser/JSON-Grep" 12 | s.license = "Apache-2.0" 13 | 14 | s.authors = ["P Loubser", "Dominic Cleal", "R.I. Pienaar"] 15 | s.email = ["ploubser@gmail.com", "dominic@cleal.org", "rip@devco.net"] 16 | 17 | s.files = `git ls-files`.split("\n") - Dir[".*", "Gem*", "*.gemspec"] 18 | s.extra_rdoc_files = [ 19 | "CHANGELOG.markdown", 20 | "README.markdown", 21 | ] 22 | s.require_paths = ["lib"] 23 | s.executables = ["jgrep"] 24 | s.default_executable = "jgrep" 25 | s.has_rdoc = true 26 | end 27 | -------------------------------------------------------------------------------- /lib/jgrep.rb: -------------------------------------------------------------------------------- 1 | require "parser/parser.rb" 2 | require "parser/scanner.rb" 3 | require "rubygems" 4 | require "json" 5 | 6 | module JGrep 7 | @verbose = false 8 | @flatten = false 9 | 10 | def self.verbose_on 11 | @verbose = true 12 | end 13 | 14 | def self.flatten_on 15 | @flatten = true 16 | end 17 | 18 | # Parse json and return documents that match the logical expression 19 | # Filters define output by limiting it to only returning a the listed keys. 20 | # Start allows you to move the pointer indicating where parsing starts. 21 | # Default is the first key in the document heirarchy 22 | def self.jgrep(json, expression, filters = nil, start = nil) 23 | errors = "" 24 | 25 | begin 26 | JSON.create_id = nil 27 | json = JSON.parse(json) 28 | json = [json] if json.is_a?(Hash) 29 | 30 | json = filter_json(json, start).flatten if start 31 | 32 | result = [] 33 | 34 | if expression == "" 35 | result = json 36 | else 37 | call_stack = Parser.new(expression).execution_stack 38 | 39 | json.each do |document| 40 | begin 41 | result << document if eval_statement(document, call_stack) 42 | rescue Exception => e # rubocop:disable Lint/RescueException 43 | if @verbose 44 | require "pp" 45 | pp document 46 | STDERR.puts "Error - #{e} \n\n" 47 | else 48 | errors = "One or more the json documents could not be parsed. Run jgrep -v for to display documents" 49 | end 50 | end 51 | end 52 | end 53 | 54 | puts errors unless errors == "" 55 | 56 | return result unless filters 57 | 58 | filter_json(result, filters) 59 | rescue JSON::ParserError 60 | STDERR.puts "Error. Invalid JSON given" 61 | end 62 | end 63 | 64 | # Validates an expression, true when no errors are found else a string representing the issues 65 | def self.validate_expression(expression) 66 | Parser.new(expression) 67 | true 68 | rescue 69 | $!.message 70 | end 71 | 72 | # Strips filters from json documents and returns those values as a less bloated json document 73 | def self.filter_json(documents, filters) 74 | result = [] 75 | 76 | if filters.is_a? Array 77 | documents.each do |doc| 78 | tmp_json = {} 79 | 80 | filters.each do |filter| 81 | filtered_result = dig_path(doc, filter) 82 | unless (filtered_result == doc) || filtered_result.nil? 83 | tmp_json[filter] = filtered_result 84 | end 85 | end 86 | result << tmp_json 87 | end 88 | else 89 | documents.each do |r| 90 | filtered_result = dig_path(r, filters) 91 | 92 | unless (filtered_result == r) || filtered_result.nil? 93 | result << filtered_result 94 | end 95 | end 96 | end 97 | 98 | result.flatten if @flatten == true && result.size == 1 99 | 100 | result 101 | end 102 | 103 | # Validates if filters do not match any of the parser's logical tokens 104 | def self.validate_filters(filters) 105 | if filters.is_a? Array 106 | filters.each do |filter| 107 | if filter =~ /=|<|>|^and$|^or$|^!$|^not$/ 108 | raise "Invalid field for -s filter : '#{filter}'" 109 | end 110 | end 111 | elsif filters =~ /=|<|>|^and$|^or$|^!$|^not$/ 112 | raise "Invalid field for -s filter : '#{filters}'" 113 | end 114 | 115 | nil 116 | end 117 | 118 | # Correctly format values so we can do the correct type of comparison 119 | def self.format(kvalue, value) 120 | if kvalue.to_s =~ /^\d+$/ && value.to_s =~ /^\d+$/ 121 | [Integer(kvalue), Integer(value)] 122 | elsif kvalue.to_s =~ /^\d+\.\d+$/ && value.to_s =~ /^\d+\.\d+$/ 123 | [Float(kvalue), Float(value)] 124 | else 125 | [kvalue, value] 126 | end 127 | end 128 | 129 | # Check if the json key that is defined by statement is defined in the json document 130 | def self.present?(document, statement) 131 | statement.split(".").each do |key| 132 | if document.is_a? Hash 133 | if document.value?(nil) 134 | document.each do |k, _| 135 | document[k] = "null" if document[k].nil? 136 | end 137 | end 138 | end 139 | 140 | if document.is_a? Array 141 | rval = false 142 | document.each do |doc| 143 | rval ||= present?(doc, key) 144 | end 145 | return rval 146 | end 147 | 148 | document = document[key] 149 | 150 | return false if document.nil? 151 | end 152 | 153 | true 154 | end 155 | 156 | # Check if key=value is present in document 157 | def self.has_object?(document, statement) 158 | key, value = statement.split(/<=|>=|=|<|>/) 159 | 160 | if statement =~ /(<=|>=|<|>|=)/ 161 | op = $1 162 | else 163 | op = statement 164 | end 165 | 166 | tmp = dig_path(document, key) 167 | 168 | tmp = tmp.first if tmp.is_a?(Array) && tmp.size == 1 169 | 170 | tmp, value = format(tmp, (value.gsub(/"|'/, "") unless value.nil?)) # rubocop:disable Style/FormatString 171 | 172 | # Deal with null comparison 173 | return true if tmp.nil? && value == "null" 174 | 175 | # Deal with booleans 176 | return true if tmp == true && value == "true" 177 | return true if tmp == false && value == "false" 178 | 179 | # Deal with regex matching 180 | if !tmp.nil? && tmp.is_a?(String) && value =~ /^\/.*\/$/ 181 | tmp.match(Regexp.new(value.delete("/"))) ? (return true) : (return false) 182 | end 183 | 184 | # Deal with everything else 185 | case op 186 | when "=" 187 | return tmp == value 188 | when "<=" 189 | return tmp <= value 190 | when ">=" 191 | return tmp >= value 192 | when ">" 193 | return tmp > value 194 | when "<" 195 | return tmp < value 196 | end 197 | end 198 | 199 | # Check if key=value is present in a sub array 200 | def self.is_object_in_array?(document, statement) 201 | document.each do |item| 202 | return true if has_object?(item, statement) 203 | end 204 | 205 | false 206 | end 207 | 208 | # Check if complex statement (defined as [key=value...]) is 209 | # present over an array of key value pairs 210 | def self.has_complex?(document, compound) 211 | field = "" 212 | tmp = document 213 | result = [] 214 | fresult = [] 215 | 216 | compound.each do |token| 217 | if token[0] == "statement" 218 | field = token 219 | break 220 | end 221 | end 222 | 223 | field = field[1].split(/=|<|>/).first 224 | 225 | field.split(".").each_with_index do |item, _| 226 | tmp = tmp[item] 227 | 228 | return false if tmp.nil? 229 | 230 | next unless tmp.is_a?(Array) 231 | 232 | tmp.each do |doc| 233 | result = [] 234 | 235 | compound.each do |token| 236 | case token[0] 237 | when "and" 238 | result << "&&" 239 | when "or" 240 | result << "||" 241 | when /not|\!/ 242 | result << "!" 243 | when "statement" 244 | op = token[1].match(/.*<=|>=|=|<|>/) 245 | left = token[1].split(op[0]).first.split(".").last 246 | right = token[1].split(op[0]).last 247 | new_statement = left + op[0] + right 248 | result << has_object?(doc, new_statement) 249 | end 250 | end 251 | 252 | fresult << eval(result.join(" ")) # rubocop:disable Security/Eval 253 | (fresult << "||") unless doc == tmp.last 254 | end 255 | 256 | return eval(fresult.join(" ")) # rubocop:disable Security/Eval 257 | end 258 | end 259 | 260 | # Evaluates the call stack en returns true of selected document 261 | # matches logical expression 262 | def self.eval_statement(document, callstack) 263 | result = [] 264 | 265 | callstack.each do |expression| 266 | case expression.keys.first 267 | when "statement" 268 | if expression.values.first.is_a?(Array) 269 | result << has_complex?(document, expression.values.first) 270 | else 271 | result << has_object?(document, expression.values.first) 272 | end 273 | when "+" 274 | result << present?(document, expression.values.first) 275 | when "-" 276 | result << !present?(document, expression.values.first) 277 | when "and" 278 | result << "&&" 279 | when "or" 280 | result << "||" 281 | when "(" 282 | result << "(" 283 | when ")" 284 | result << ")" 285 | when "not" 286 | result << "!" 287 | end 288 | end 289 | 290 | eval(result.join(" ")) # rubocop:disable Security/Eval 291 | end 292 | 293 | # Digs to a specific path in the json document and returns the value 294 | def self.dig_path(json, path) 295 | index = nil 296 | path = path.gsub(/^\./, "") 297 | 298 | if path =~ /(.*)\[(.*)\]/ 299 | path = $1 300 | index = $2 301 | end 302 | 303 | return json if path == "" 304 | 305 | if json.is_a? Hash 306 | json.keys.each do |k| 307 | if path.start_with?(k) && k.include?(".") 308 | return dig_path(json[k], path.gsub(k, "")) 309 | end 310 | end 311 | end 312 | 313 | path_array = path.split(".") 314 | 315 | if path_array.first == "*" 316 | tmp = [] 317 | 318 | json.each do |j| 319 | tmp << dig_path(j[1], path_array.drop(1).join(".")) 320 | end 321 | 322 | return tmp 323 | end 324 | 325 | json = json[path_array.first] if json.is_a? Hash 326 | 327 | if json.is_a? Hash 328 | return json if path == path_array.first 329 | return dig_path(json, path.include?(".") ? path_array.drop(1).join(".") : path) 330 | 331 | elsif json.is_a? Array 332 | if path == path_array.first && (json.first.is_a?(Hash) && !json.first.keys.include?(path)) 333 | return json 334 | end 335 | 336 | tmp = [] 337 | 338 | json.each do |j| 339 | tmp_path = dig_path(j, (path.include?(".") ? path_array.drop(1).join(".") : path)) 340 | tmp << tmp_path unless tmp_path.nil? 341 | end 342 | 343 | unless tmp.empty? 344 | return index ? tmp.flatten[index.to_i] : tmp 345 | end 346 | 347 | elsif json.nil? 348 | return nil 349 | 350 | else 351 | return json 352 | 353 | end 354 | end 355 | end 356 | -------------------------------------------------------------------------------- /lib/parser/parser.rb: -------------------------------------------------------------------------------- 1 | module JGrep 2 | class Parser 3 | attr_reader :scanner, :execution_stack 4 | 5 | def initialize(args) 6 | @scanner = Scanner.new(args) 7 | @execution_stack = [] 8 | 9 | parse 10 | end 11 | 12 | # Parse the input string, one token at a time a contruct the call stack 13 | def parse(substatement = nil, token_index = 0) 14 | p_token = nil 15 | 16 | if substatement 17 | c_token, c_token_value = substatement[token_index] 18 | else 19 | c_token, c_token_value = @scanner.get_token 20 | end 21 | 22 | parenth = 0 23 | 24 | until c_token.nil? 25 | if substatement 26 | token_index += 1 27 | n_token, n_token_value = substatement[token_index] 28 | else 29 | @scanner.token_index += 1 30 | n_token, n_token_value = @scanner.get_token 31 | end 32 | 33 | next if n_token == " " 34 | case c_token 35 | 36 | when "and" 37 | unless (n_token =~ /not|statement|\(|\+|-/) || (scanner.token_index == scanner.arguments.size) 38 | raise "Error at column #{scanner.token_index}. \nExpected 'not', 'statement' or '('. Found '#{n_token_value}'" 39 | end 40 | 41 | raise "Error at column #{scanner.token_index}. \n Expression cannot start with 'and'" if p_token.nil? 42 | raise "Error at column #{scanner.token_index}. \n #{p_token} cannot be followed by 'and'" if %w[and or].include?(p_token) 43 | 44 | when "or" 45 | unless (n_token =~ /not|statement|\(|\+|-/) || (scanner.token_index == scanner.arguments.size) 46 | raise "Error at column #{scanner.token_index}. \nExpected 'not', 'statement', '('. Found '#{n_token_value}'" 47 | end 48 | 49 | raise "Error at column #{scanner.token_index}. \n Expression cannot start with 'or'" if p_token.nil? 50 | raise "Error at column #{scanner.token_index}. \n #{p_token} cannot be followed by 'or'" if %w[and or].include?(p_token) 51 | 52 | when "not" 53 | unless n_token =~ /statement|\(|not|\+|-/ 54 | raise "Error at column #{scanner.token_index}. \nExpected 'statement' or '('. Found '#{n_token_value}'" 55 | end 56 | 57 | when "statement" 58 | if c_token_value.is_a? Array 59 | raise "Error at column #{scanner.token_index}\nError, cannot define '[' in a '[...]' block." if substatement 60 | 61 | parse(c_token_value, 0) 62 | end 63 | 64 | if c_token_value.is_a?(String) && c_token_value =~ /!=/ 65 | c_token_value = c_token_value.gsub("!=", "=") 66 | @execution_stack << {"not" => "not"} 67 | end 68 | 69 | if !n_token.nil? && !n_token.match(/and|or|\)/) 70 | raise "Error at column #{scanner.token_index}. \nExpected 'and', 'or', ')'. Found '#{n_token_value}'" 71 | end 72 | 73 | when "+" 74 | if !n_token.nil? && !n_token.match(/and|or|\)/) 75 | raise "Error at column #{scanner.token_index}. \nExpected 'and', 'or', ')'. Found '#{n_token_value}'" 76 | end 77 | 78 | when "-" 79 | if !n_token.nil? && !n_token.match(/and|or|\)/) 80 | raise "Error at column #{scanner.token_index}. \nExpected 'and', 'or', ')'. Found '#{n_token_value}'" 81 | end 82 | 83 | when ")" 84 | if !n_token.nil? && n_token !~ /|and|or|not|\(/ 85 | raise "Error at column #{scanner.token_index}. \nExpected 'and', 'or', 'not' or '('. Found '#{n_token_value}'" 86 | end 87 | 88 | parenth += 1 89 | 90 | when "(" 91 | unless n_token =~ /statement|not|\(|\+|-/ 92 | raise "Error at column #{scanner.token_index}. \nExpected 'statement', '(', not. Found '#{n_token_value}'" 93 | end 94 | 95 | parenth -= 1 96 | 97 | else 98 | raise "Unexpected token found at column #{scanner.token_index}. '#{c_token_value}'" 99 | end 100 | 101 | unless n_token == " " || substatement 102 | @execution_stack << {c_token => c_token_value} 103 | end 104 | 105 | p_token = c_token 106 | c_token = n_token 107 | c_token_value = n_token_value 108 | end 109 | 110 | return if substatement 111 | 112 | raise "Error. Missing parentheses ')'." if parenth < 0 113 | raise "Error. Missing parentheses '('." if parenth > 0 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/parser/scanner.rb: -------------------------------------------------------------------------------- 1 | module JGrep 2 | class Scanner 3 | attr_accessor :arguments, :token_index 4 | 5 | def initialize(arguments) 6 | @token_index = 0 7 | @arguments = arguments 8 | end 9 | 10 | # Scans the input string and identifies single language tokens 11 | def get_token 12 | return nil if @token_index >= @arguments.size 13 | 14 | begin 15 | case chr(@arguments[@token_index]) 16 | when "[" 17 | return "statement", gen_substatement 18 | 19 | when "]" 20 | return "]" 21 | 22 | when "(" 23 | return "(", "(" 24 | 25 | when ")" 26 | return ")", ")" 27 | 28 | when "n" 29 | if (chr(@arguments[@token_index + 1]) == "o") && (chr(@arguments[@token_index + 2]) == "t") && ((chr(@arguments[@token_index + 3]) == " ") || (chr(@arguments[@token_index + 3]) == "(")) 30 | @token_index += 2 31 | return "not", "not" 32 | else 33 | gen_statement 34 | end 35 | 36 | when "!" 37 | return "not", "not" 38 | 39 | when "a" 40 | if (chr(@arguments[@token_index + 1]) == "n") && (chr(@arguments[@token_index + 2]) == "d") && ((chr(@arguments[@token_index + 3]) == " ") || (chr(@arguments[@token_index + 3]) == "(")) 41 | @token_index += 2 42 | return "and", "and" 43 | else 44 | gen_statement 45 | end 46 | 47 | when "&" 48 | if chr(@arguments[@token_index + 1]) == "&" 49 | @token_index += 1 50 | return "and", "and" 51 | else 52 | gen_statement 53 | end 54 | 55 | when "o" 56 | if (chr(@arguments[@token_index + 1]) == "r") && ((chr(@arguments[@token_index + 2]) == " ") || (chr(@arguments[@token_index + 2]) == "(")) 57 | @token_index += 1 58 | return "or", "or" 59 | else 60 | gen_statement 61 | end 62 | 63 | when "|" 64 | if chr(@arguments[@token_index + 1]) == "|" 65 | @token_index += 1 66 | return "or", "or" 67 | else 68 | gen_statement 69 | end 70 | 71 | when "+" 72 | value = "" 73 | i = @token_index + 1 74 | 75 | begin 76 | value += chr(@arguments[i]) 77 | i += 1 78 | end until (i >= @arguments.size) || (chr(@arguments[i]) =~ /\s|\)/) 79 | 80 | @token_index = i - 1 81 | return "+", value 82 | 83 | when "-" 84 | value = "" 85 | i = @token_index + 1 86 | 87 | begin 88 | value += chr(@arguments[i]) 89 | i += 1 90 | end until (i >= @arguments.size) || (chr(@arguments[i]) =~ /\s|\)/) 91 | 92 | @token_index = i - 1 93 | return "-", value 94 | 95 | when " " 96 | return " ", " " 97 | 98 | else 99 | gen_statement 100 | end 101 | end 102 | rescue NoMethodError 103 | raise "Error. Expression cannot be parsed." 104 | end 105 | 106 | private 107 | 108 | def gen_substatement 109 | @token_index += 1 110 | returnval = [] 111 | 112 | while (val = get_token) != "]" 113 | @token_index += 1 114 | returnval << val unless val[0] == " " 115 | end 116 | 117 | returnval 118 | end 119 | 120 | def gen_statement 121 | current_token_value = "" 122 | j = @token_index 123 | 124 | begin 125 | if chr(@arguments[j]) == "/" 126 | begin 127 | current_token_value << chr(@arguments[j]) 128 | j += 1 129 | if chr(@arguments[j]) == "/" 130 | current_token_value << "/" 131 | break 132 | end 133 | end until (j >= @arguments.size) || (chr(@arguments[j]) =~ /\//) 134 | else 135 | begin 136 | current_token_value << chr(@arguments[j]) 137 | j += 1 138 | if chr(@arguments[j]) =~ /'|"/ 139 | begin 140 | current_token_value << chr(@arguments[j]) 141 | j += 1 142 | end until (j >= @arguments.size) || (chr(@arguments[j]) =~ /'|"/) 143 | end 144 | end until (j >= @arguments.size) || (chr(@arguments[j]) =~ /\s|\)|\]/ && chr(@arguments[j - 1]) != '\\') 145 | end 146 | rescue 147 | raise "Invalid token found - '#{current_token_value}'" 148 | end 149 | 150 | if current_token_value =~ /^(and|or|not|!)$/ 151 | raise "Class name cannot be 'and', 'or', 'not'. Found '#{current_token_value}'" 152 | end 153 | 154 | @token_index += current_token_value.size - 1 155 | 156 | ["statement", current_token_value] 157 | end 158 | 159 | # Compatibility with 1.8.7, which returns a Fixnum from String#[] 160 | def chr(character) 161 | character.chr unless character.nil? 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/Rakefile: -------------------------------------------------------------------------------- 1 | require "spec_helper.rb" 2 | require "rake" 3 | require "rspec/core/rake_task" 4 | 5 | desc "Run JGrep tests" 6 | RSpec::Core::RakeTask.new(:test) do |t| 7 | t.pattern = "unit/*_spec.rb" 8 | t.rspec_opts = "--format s --color --backtrace" 9 | end 10 | 11 | task default: :test 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rspec" 3 | require "rspec/mocks" 4 | require "mocha" 5 | require "jgrep" 6 | 7 | RSpec.configure do |config| 8 | config.mock_with :mocha 9 | end 10 | -------------------------------------------------------------------------------- /spec/unit/jgrep_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + "/../spec_helper" 2 | 3 | module JGrep 4 | describe JGrep do 5 | describe "#validate_expression" do 6 | it "should be true for valid expressions" do 7 | expect(JGrep.validate_expression("bob=true")).to be(true) 8 | end 9 | 10 | it "should return errors for invalid ones" do 11 | expect(JGrep.validate_expression("something that is invalid")).to start_with("Error") 12 | end 13 | end 14 | 15 | describe "#jgrep" do 16 | it "should return a valid json document" do 17 | result = JGrep.jgrep("[{\"foo\":1}]", "foo=1") 18 | expect(result).to eq([{"foo" => 1}]) 19 | end 20 | 21 | it "should fail on an invalid json document" do 22 | STDERR.expects(:puts).with("Error. Invalid JSON given") 23 | JGrep.jgrep("[foo:]", "foo=1") 24 | end 25 | 26 | it "should return '[]' if value is not present in document" do 27 | result = JGrep.jgrep("[{\"bar\":1}]", "foo=1") 28 | expect(result).to eq([]) 29 | end 30 | 31 | it "should correctly return 'null' if a null value is present in the document" do 32 | result = JGrep.jgrep("[{\"foo\":null}]", "foo=null") 33 | expect(result).to eq([{"foo" => nil}]) 34 | end 35 | 36 | it "should return the origional json document if no expression is given" do 37 | result = JGrep.jgrep("[{\"foo\":\"bar\"}]", "") 38 | expect(result).to eq([{"foo" => "bar"}]) 39 | end 40 | 41 | it "should filter on the origional json document if not expression is given and a filter is given" do 42 | result = JGrep.jgrep("[{\"foo\":\"bar\"}]", "", "foo") 43 | expect(result).to eq(["bar"]) 44 | end 45 | 46 | it "should support starting from a subdocument" do 47 | doc = ' 48 | {"results": [ 49 | {"foo":"bar"}, 50 | {"foo":"baz"} 51 | ] 52 | } 53 | ' 54 | 55 | JGrep.verbose_on 56 | results = JGrep.jgrep(doc, "foo=bar", nil, "results") 57 | expect(results).to eq([{"foo" => "bar"}]) 58 | end 59 | end 60 | 61 | describe "#format" do 62 | it "should correctly format integers" do 63 | result1, result2 = JGrep.format("1", 1) 64 | expect(result1.is_a?(Integer)).to eq(true) 65 | expect(result2.is_a?(Integer)).to eq(true) 66 | end 67 | 68 | it "should correctly format floating point numbers" do 69 | result1, result2 = JGrep.format("1.1", 1.1) 70 | expect(result1.is_a?(Float)).to eq(true) 71 | expect(result2.is_a?(Float)).to eq(true) 72 | end 73 | 74 | it "should not format strings" do 75 | result1, result2 = JGrep.format("foo", "bar") 76 | expect(result1.is_a?(String)).to eq(true) 77 | expect(result2.is_a?(String)).to eq(true) 78 | end 79 | 80 | it 'should not format strings with a single [^\d\.] character' do 81 | result1, result2 = JGrep.format("2012R2", "2008R2") 82 | expect(result1).to be_a(String) 83 | expect(result2).to be_a(String) 84 | end 85 | end 86 | 87 | describe "#has_object?" do 88 | it "should compare on a '=' operator" do 89 | result = JGrep.has_object?({"foo" => 1}, "foo=1") 90 | expect(result).to eq(true) 91 | end 92 | 93 | it "should compare on a '<=' operator" do 94 | result = JGrep.has_object?({"foo" => 1}, "foo<=0") 95 | expect(result).to eq(false) 96 | end 97 | 98 | it "should compare on a '>=' operator" do 99 | result = JGrep.has_object?({"foo" => 1}, "foo>=0") 100 | expect(result).to eq(true) 101 | end 102 | 103 | it "should compare on a '<' operator" do 104 | result = JGrep.has_object?({"foo" => 1}, "foo<1") 105 | expect(result).to eq(false) 106 | end 107 | 108 | it "should compare on a '>' operator" do 109 | result = JGrep.has_object?({"foo" => 1}, "foo>0") 110 | expect(result).to eq(true) 111 | end 112 | 113 | it "should compare based on regular expression" do 114 | result = JGrep.has_object?({"foo" => "bar"}, "foo=/ba/") 115 | expect(result).to eq(true) 116 | end 117 | 118 | it "should compare true booleans" do 119 | result = JGrep.has_object?({"foo" => true}, "foo=true") 120 | expect(result).to eq(true) 121 | result = JGrep.has_object?({"foo" => false}, "foo=true") 122 | expect(result).to eq(false) 123 | end 124 | 125 | it "should compare true booleans" do 126 | result = JGrep.has_object?({"foo" => false}, "foo=false") 127 | expect(result).to eq(true) 128 | result = JGrep.has_object?({"foo" => true}, "foo=false") 129 | expect(result).to eq(false) 130 | end 131 | end 132 | 133 | describe "#is_object_in_array?" do 134 | it "should return true if key=value is present in array" do 135 | result = JGrep.is_object_in_array?([{"foo" => 1}, {"foo" => 0}], "foo=1") 136 | expect(result).to eq(true) 137 | end 138 | 139 | it "should return false if key=value is not present in array" do 140 | result = JGrep.is_object_in_array?([{"foo" => 1}, {"foo" => 0}], "foo=2") 141 | expect(result).to eq(false) 142 | end 143 | end 144 | 145 | describe "#has_complex?" do 146 | it "should return true if complex statement is present in an array" do 147 | result = JGrep.has_complex?({"foo" => ["bar" => 1]}, [["statement", "foo.bar=1"]]) 148 | expect(result).to eq(true) 149 | end 150 | 151 | it "should return false if complex statement is not present in an array" do 152 | result = JGrep.has_complex?({"foo" => ["bar" => 1]}, [["statement", "foo.bar=0"]]) 153 | expect(result).to eq(false) 154 | end 155 | end 156 | 157 | describe "#eval_statement" do 158 | it "should return true if if document matches logical expression" do 159 | result = JGrep.eval_statement({"foo" => 1, "bar" => 1}, [{"statement" => "foo=1"}, {"and" => "and"}, {"statement" => "bar=1"}]) 160 | expect(result).to eq(true) 161 | end 162 | 163 | it "should return true if if document matches logical expression array" do 164 | result = JGrep.eval_statement({"foo" => ["bar" => 1]}, [{"statement" => [["statement", "foo.bar=1"]]}]) 165 | expect(result).to eq(true) 166 | end 167 | 168 | it "should return false if if document doesn't match logical expression" do 169 | result = JGrep.eval_statement({"foo" => 1, "bar" => 1}, [{"statement" => "foo=0"}, {"and" => "and"}, {"statement" => "bar=1"}]) 170 | expect(result).to eq(false) 171 | end 172 | end 173 | 174 | describe "#filter_json" do 175 | it "should return the correct values if there is a single filter" do 176 | result = JGrep.filter_json([{"foo" => 1, "bar" => 1}], "foo") 177 | expect(result).to eq([1]) 178 | end 179 | 180 | it "should return the correct values if there are multiple filters" do 181 | result = JGrep.filter_json([{"foo" => 1, "foo1" => 1, "foo2" => 1}], %w[foo2 foo1]) 182 | expect(result).to eq([{"foo2" => 1, "foo1" => 1}]) 183 | end 184 | 185 | it "should return an empty set if the filter has not been found and there is only 1 filter" do 186 | result = JGrep.filter_json([{"foo" => 1}], "bar") 187 | expect(result).to eq([]) 188 | end 189 | 190 | it "should not return a structure containing a key if that key is not specified in the document" do 191 | result = JGrep.filter_json([{"foo" => 1}], %w[foo bar]) 192 | expect(result).to eq([{"foo" => 1}]) 193 | end 194 | end 195 | 196 | describe "#validate_filters" do 197 | it "should validate correct single filter" do 198 | result = JGrep.validate_filters("foo") 199 | expect(result).to be_nil 200 | end 201 | 202 | it "should not validate if a single filter contains an invalid field" do 203 | expect do 204 | JGrep.validate_filters("and") 205 | end.to raise_error "Invalid field for -s filter : 'and'" 206 | end 207 | 208 | it "should correctly validate an array of filters" do 209 | result = JGrep.validate_filters(%w[foo bar]) 210 | expect(result).to be_nil 211 | end 212 | 213 | it "should not validate if an array of filters contain an illegal filter" do 214 | expect do 215 | JGrep.validate_filters(%w[foo or]) 216 | end.to raise_error "Invalid field for -s filter : 'or'" 217 | end 218 | end 219 | 220 | describe "#dig_path" do 221 | it "should return the correct key value for a hash" do 222 | result = JGrep.dig_path({"foo" => 1}, "foo") 223 | expect(result).to eq(1) 224 | end 225 | 226 | it "should return the correct value for any value that is not a hash or an array" do 227 | result = JGrep.dig_path(1, "foo") 228 | expect(result).to eq(1) 229 | end 230 | 231 | it "should return the correct value for a subvalue in an array" do 232 | result = JGrep.dig_path([{"foo" => 1}, {"foo" => 2}], "foo") 233 | expect(result).to eq([1, 2]) 234 | end 235 | 236 | it "should return the correct value if a wildcard is specified" do 237 | result = JGrep.dig_path([{"foo" => {"bar" => 1}}], "foo.*") 238 | expect(result).to eq([[{"bar" => 1}]]) 239 | end 240 | 241 | it "should return the correct value if the path contains a dot seperated key" do 242 | result = JGrep.dig_path({"foo.bar" => 1}, "foo.bar") 243 | expect(result).to eq(1) 244 | result = JGrep.dig_path({"foo" => {"foo.bar" => 1}}, "foo.foo.bar") 245 | expect(result).to eq(1) 246 | end 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /spec/unit/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + "/../spec_helper" 2 | 3 | module JGrep 4 | describe Parser do 5 | describe "#parse" do 6 | it "should parse statements seperated by '='" do 7 | parser = Parser.new("foo.bar=bar") 8 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar=bar"}]) 9 | end 10 | 11 | it "should parse statements seperated by '<'" do 12 | parser = Parser.new("foo.bar<1") 13 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar<1"}]) 14 | end 15 | 16 | it "should parse statements seperated by '>'" do 17 | parser = Parser.new("foo.bar>1") 18 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar>1"}]) 19 | end 20 | 21 | it "should parse statements seperated by '<='" do 22 | parser = Parser.new("foo.bar<=1") 23 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar<=1"}]) 24 | end 25 | 26 | it "should parse statements seperated by '>='" do 27 | parser = Parser.new("foo.bar>=1") 28 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar>=1"}]) 29 | end 30 | 31 | it "should parse statement sperated by '!='" do 32 | parser = Parser.new("foo.bar!=1") 33 | expect(parser.execution_stack).to eq([{"not" => "not"}, {"statement" => "foo.bar=1"}]) 34 | end 35 | 36 | it "should parse a + token" do 37 | parser = Parser.new("+foo") 38 | expect(parser.execution_stack).to eq([{"+" => "foo"}]) 39 | end 40 | 41 | it "should parse a - token" do 42 | parser = Parser.new("-foo") 43 | expect(parser.execution_stack).to eq([{"-" => "foo"}]) 44 | end 45 | 46 | it "should parse a correct 'and' token" do 47 | parser = Parser.new("foo.bar=123 and bar.foo=321") 48 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar=123"}, {"and" => "and"}, {"statement" => "bar.foo=321"}]) 49 | end 50 | 51 | it "should not parse an incorrect and token" do 52 | expect do 53 | Parser.new("and foo.bar=1") 54 | end.to raise_error("Error at column 12. \n Expression cannot start with 'and'") 55 | end 56 | 57 | it "should parse a correct 'or' token" do 58 | parser = Parser.new("foo.bar=1 or bar.foo=1") 59 | expect(parser.execution_stack).to eq([{"statement" => "foo.bar=1"}, {"or" => "or"}, {"statement" => "bar.foo=1"}]) 60 | end 61 | 62 | it "should not parse an incorrect and token" do 63 | expect do 64 | Parser.new("or foo.bar=1") 65 | end.to raise_error("Error at column 11. \n Expression cannot start with 'or'") 66 | end 67 | 68 | it "should parse a correct 'not' token" do 69 | parser = Parser.new("! bar.foo=1") 70 | expect(parser.execution_stack).to eq([{"not" => "not"}, {"statement" => "bar.foo=1"}]) 71 | parser = Parser.new("not bar.foo=1") 72 | expect(parser.execution_stack).to eq([{"not" => "not"}, {"statement" => "bar.foo=1"}]) 73 | end 74 | 75 | it "should not parse an incorrect 'not' token" do 76 | expect do 77 | Parser.new("foo.bar=1 !") 78 | end.to raise_error("Error at column 10. \nExpected 'and', 'or', ')'. Found 'not'") 79 | end 80 | 81 | it "should parse correct parentheses" do 82 | parser = Parser.new("(foo.bar=1)") 83 | expect(parser.execution_stack).to eq([{"(" => "("}, {"statement" => "foo.bar=1"}, {")" => ")"}]) 84 | end 85 | 86 | it "should fail on incorrect parentheses" do 87 | expect do 88 | Parser.new(")foo.bar=1(") 89 | end.to raise_error("Error. Missing parentheses '('.") 90 | end 91 | 92 | it "should fail on missing parentheses" do 93 | expect do 94 | Parser.new("(foo.bar=1") 95 | end.to raise_error("Error. Missing parentheses ')'.") 96 | end 97 | 98 | it "should parse correctly formatted compound statements" do 99 | parser = Parser.new("(foo.bar=1 or foo.rab=1) and (bar.foo=1)") 100 | expect(parser.execution_stack).to eq([{"(" => "("}, {"statement" => "foo.bar=1"}, {"or" => "or"}, {"statement" => "foo.rab=1"}, 101 | {")" => ")"}, {"and" => "and"}, {"(" => "("}, {"statement" => "bar.foo=1"}, 102 | {")" => ")"}]) 103 | end 104 | 105 | it "should parse complex array statements" do 106 | parser = Parser.new("[foo.bar=1]") 107 | expect(parser.execution_stack).to eq([{"statement" => [["statement", "foo.bar=1"]]}]) 108 | end 109 | 110 | it "should not parse failed complex array statements" do 111 | expect do 112 | Parser.new("[foo.bar=1 or]") 113 | end.to raise_error("Class name cannot be 'and', 'or', 'not'. Found 'or'") 114 | end 115 | 116 | it "should not allow nested complex array statements" do 117 | expect do 118 | Parser.new("[foo.bar=1 and [foo.bar=1]]") 119 | end.to raise_error("Error at column 27\nError, cannot define '[' in a '[...]' block.") 120 | end 121 | 122 | it "should parse complex, compound array statements" do 123 | parser = Parser.new("[foo.bar=1 and foo.rab=2] and !(foo=1)") 124 | expect(parser.execution_stack).to eq( 125 | [ 126 | {"statement" => [["statement", "foo.bar=1"], %w[and and], ["statement", "foo.rab=2"]]}, 127 | {"and" => "and"}, 128 | {"not" => "not"}, 129 | {"(" => "("}, 130 | {"statement" => "foo=1"}, 131 | {")" => ")"} 132 | ] 133 | ) 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/unit/scanner_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + "/../spec_helper" 2 | 3 | module JGrep 4 | describe Scanner do 5 | describe "#get_token" do 6 | it "should identify a '(' token" do 7 | scanner = Scanner.new("(") 8 | token = scanner.get_token 9 | expect(token).to eq(["(", "("]) 10 | end 11 | 12 | it "should identify a ')' token" do 13 | scanner = Scanner.new(")") 14 | token = scanner.get_token 15 | expect(token).to eq([")", ")"]) 16 | end 17 | 18 | it "should identify an 'and' token" do 19 | scanner = Scanner.new("and ") 20 | token = scanner.get_token 21 | expect(token).to eq(%w[and and]) 22 | end 23 | 24 | it "should identify a '&&' token" do 25 | scanner = Scanner.new("&& ") 26 | token = scanner.get_token 27 | expect(token).to eq(%w[and and]) 28 | end 29 | 30 | it "should identify an 'or' token" do 31 | scanner = Scanner.new("or ") 32 | token = scanner.get_token 33 | expect(token).to eq(%w[or or]) 34 | end 35 | 36 | it "should identify a " || " token" do 37 | scanner = Scanner.new("|| ") 38 | token = scanner.get_token 39 | expect(token).to eq(%w[or or]) 40 | end 41 | 42 | it "should identify an 'not' token" do 43 | scanner = Scanner.new("not ") 44 | token = scanner.get_token 45 | expect(token).to eq(%w[not not]) 46 | end 47 | 48 | it "should identify an '!' token" do 49 | scanner = Scanner.new("!") 50 | token = scanner.get_token 51 | expect(token).to eq(%w[not not]) 52 | end 53 | 54 | it "should identify a statement token" do 55 | scanner = Scanner.new("foo.bar=bar") 56 | token = scanner.get_token 57 | expect(token).to eq(["statement", "foo.bar=bar"]) 58 | end 59 | 60 | it "should identify a statement token with escaped parentheses" do 61 | scanner = Scanner.new("foo.bar=/baz\\(gronk\\)quux/") 62 | token = scanner.get_token 63 | expect(token).to eq(["statement", "foo.bar=/baz\\(gronk\\)quux/"]) 64 | end 65 | 66 | it "should identify a complex array statement" do 67 | scanner = Scanner.new("[foo=bar and bar=foo]") 68 | token = scanner.get_token 69 | expect(token).to eq(["statement", [["statement", "foo=bar"], %w[and and], ["statement", "bar=foo"]]]) 70 | end 71 | 72 | it "should fail if expression terminates with 'and'" do 73 | scanner = Scanner.new("and") 74 | 75 | expect do 76 | scanner.get_token 77 | end.to raise_error("Class name cannot be 'and', 'or', 'not'. Found 'and'") 78 | end 79 | 80 | it "should identify a '+' token" do 81 | scanner = Scanner.new("+foo") 82 | token = scanner.get_token 83 | expect(token).to eq(["+", "foo"]) 84 | end 85 | 86 | it "should identify a '-' token" do 87 | scanner = Scanner.new("-foo") 88 | token = scanner.get_token 89 | expect(token).to eq(["-", "foo"]) 90 | end 91 | end 92 | end 93 | end 94 | --------------------------------------------------------------------------------