From 913ad8bf2382f4c090ec91ffec2775a9cbae26b0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 6 Jan 2022 23:53:38 -0800 Subject: [PATCH 1/6] add test case for equality of complex items --- spec/compliance/boolean.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/compliance/boolean.json b/spec/compliance/boolean.json index 60635ac..9b6ac0d 100644 --- a/spec/compliance/boolean.json +++ b/spec/compliance/boolean.json @@ -207,6 +207,7 @@ "two": 2, "three": 3, "emptylist": [], + "complexlist": [{}, []], "boolvalue": false }, "cases": [ @@ -222,6 +223,22 @@ "expression": "one == one", "result": true }, + { + "expression": "emptylist == `[]`", + "result": true + }, + { + "expression": "emptylist == emptylist", + "result": true + }, + { + "expression": "complexlist == `[{}, []]`", + "result": true + }, + { + "expression": "complexlist == complexlist", + "result": true + }, { "expression": "one == two", "result": false From 198486eae56a36f87548cd8b6d3ffca56e1b5553 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 7 Jan 2022 00:01:18 -0800 Subject: [PATCH 2/6] add test case for array containing a complex item --- spec/compliance/functions.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/spec/compliance/functions.json b/spec/compliance/functions.json index d2ec936..54747d4 100644 --- a/spec/compliance/functions.json +++ b/spec/compliance/functions.json @@ -4,7 +4,7 @@ "foo": -1, "zero": 0, "numbers": [-1, 3, 4, 5], - "array": [-1, 3, 4, 5, "a", "100"], + "array": [-1, 3, 4, 5, "a", "100", [{}]], "strings": ["a", "b", "c"], "decimals": [1.01, 1.2, -1.5], "str": "Str", @@ -131,6 +131,10 @@ "expression": "contains(decimals, `false`)", "result": false }, + { + "expression": "contains(array, `[{}]`)", + "result": true + }, { "expression": "ends_with(str, 'r')", "result": true @@ -201,7 +205,7 @@ }, { "expression": "length(array)", - "result": 6 + "result": 7 }, { "expression": "length(objects)", @@ -405,7 +409,7 @@ }, { "expression": "reverse(array)", - "result": ["100", "a", 5, 4, 3, -1] + "result": [[{}], "100", "a", 5, 4, 3, -1] }, { "expression": "reverse(`[]`)", From 814f375a03d2fae624cf0414a25fc55c9e309286 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 2 Jan 2022 12:38:14 -0800 Subject: [PATCH 3/6] duck-type values responding to #to_hash, #to_ary, #to_str rather than checking class --- lib/jmespath/nodes.rb | 4 -- lib/jmespath/nodes/field.rb | 14 +++-- lib/jmespath/nodes/flatten.rb | 8 +-- lib/jmespath/nodes/function.rb | 96 +++++++++++++++++++------------- lib/jmespath/nodes/projection.rb | 8 ++- lib/jmespath/nodes/slice.rb | 6 +- lib/jmespath/util.rb | 4 +- 7 files changed, 81 insertions(+), 59 deletions(-) diff --git a/lib/jmespath/nodes.rb b/lib/jmespath/nodes.rb index e67cc0d..e9ba80e 100644 --- a/lib/jmespath/nodes.rb +++ b/lib/jmespath/nodes.rb @@ -5,10 +5,6 @@ class Node def visit(value) end - def hash_like?(value) - Hash === value || Struct === value - end - def optimize self end diff --git a/lib/jmespath/nodes/field.rb b/lib/jmespath/nodes/field.rb index 3aaee9d..0f2df46 100644 --- a/lib/jmespath/nodes/field.rb +++ b/lib/jmespath/nodes/field.rb @@ -8,9 +8,10 @@ def initialize(key) end def visit(value) - if value.is_a?(Array) && @key.is_a?(Integer) - value[@key] - elsif value.is_a?(Hash) + if value.respond_to?(:to_ary) && @key.is_a?(Integer) + value.to_ary[@key] + elsif value.respond_to?(:to_hash) + value = value.to_hash if !(v = value[@key]).nil? v elsif @key_sym && !(v = value[@key_sym]).nil? @@ -48,9 +49,10 @@ def initialize(keys) def visit(obj) @keys.reduce(obj) do |value, key| - if value.is_a?(Array) && key.is_a?(Integer) - value[key] - elsif value.is_a?(Hash) + if value.respond_to?(:to_ary) && key.is_a?(Integer) + value.to_ary[key] + elsif value.respond_to?(:to_hash) + value = value.to_hash if !(v = value[key]).nil? v elsif (sym = @key_syms[key]) && !(v = value[sym]).nil? diff --git a/lib/jmespath/nodes/flatten.rb b/lib/jmespath/nodes/flatten.rb index 1009403..7d46ece 100644 --- a/lib/jmespath/nodes/flatten.rb +++ b/lib/jmespath/nodes/flatten.rb @@ -8,10 +8,10 @@ def initialize(child) def visit(value) value = @child.visit(value) - if Array === value - value.each_with_object([]) do |v, values| - if Array === v - values.concat(v) + if value.respond_to?(:to_ary) + value.to_ary.each_with_object([]) do |v, values| + if v.respond_to?(:to_ary) + values.concat(v.to_ary) else values.push(v) end diff --git a/lib/jmespath/nodes/function.rb b/lib/jmespath/nodes/function.rb index 6a6ab52..2531715 100644 --- a/lib/jmespath/nodes/function.rb +++ b/lib/jmespath/nodes/function.rb @@ -50,14 +50,20 @@ def call(args) module TypeChecker def get_type(value) - case value - when String then STRING_TYPE - when true, false then BOOLEAN_TYPE - when nil then NULL_TYPE - when Numeric then NUMBER_TYPE - when Hash, Struct then OBJECT_TYPE - when Array then ARRAY_TYPE - when Expression then EXPRESSION_TYPE + if value.respond_to?(:to_str) + STRING_TYPE + elsif value == true || value == false + BOOLEAN_TYPE + elsif value == nil + NULL_TYPE + elsif value.is_a?(Numeric) + NUMBER_TYPE + elsif value.respond_to?(:to_hash) || value.is_a?(Struct) + OBJECT_TYPE + elsif value.respond_to?(:to_ary) + ARRAY_TYPE + elsif value.is_a?(Expression) + EXPRESSION_TYPE end end @@ -106,7 +112,8 @@ def call(args) else return maybe_raise Errors::InvalidArityError, "function avg() expects one argument" end - if Array === values + if values.respond_to?(:to_ary) + values = values.to_ary return nil if values.empty? values.inject(0) do |total,n| if Numeric === n @@ -145,8 +152,10 @@ def call(args) if args.count == 2 haystack = args[0] needle = args[1] - if String === haystack || Array === haystack - haystack.include?(needle) + if haystack.respond_to?(:to_str) + haystack.to_str.include?(needle) + elsif haystack.respond_to?(:to_ary) + haystack.to_ary.include?(needle) else return maybe_raise Errors::InvalidTypeError, "contains expects 2nd arg to be a list" end @@ -182,9 +191,14 @@ def call(args) else return maybe_raise Errors::InvalidArityError, "function length() expects one argument" end - case value - when Hash, Array, String then value.size - else return maybe_raise Errors::InvalidTypeError, "function length() expects string, array or object" + if value.respond_to?(:to_hash) + value.to_hash.size + elsif value.respond_to?(:to_ary) + value.to_ary.size + elsif value.respond_to?(:to_str) + value.to_str.size + else + return maybe_raise Errors::InvalidTypeError, "function length() expects string, array or object" end end end @@ -202,8 +216,8 @@ def call(args) else return maybe_raise Errors::InvalidTypeError, "function map() expects the first argument to be an expression" end - if Array === args[1] - list = args[1] + if args[1].respond_to?(:to_ary) + list = args[1].to_ary else return maybe_raise Errors::InvalidTypeError, "function map() expects the second argument to be an list" end @@ -223,7 +237,8 @@ def call(args) else return maybe_raise Errors::InvalidArityError, "function max() expects one argument" end - if Array === values + if values.respond_to?(:to_ary) + values = values.to_ary return nil if values.empty? first = values.first first_type = get_type(first) @@ -258,7 +273,8 @@ def call(args) else return maybe_raise Errors::InvalidArityError, "function min() expects one argument" end - if Array === values + if values.respond_to?(:to_ary) + values = values.to_ary return nil if values.empty? first = values.first first_type = get_type(first) @@ -302,12 +318,10 @@ class KeysFunction < Function def call(args) if args.count == 1 value = args.first - if hash_like?(value) - case value - when Hash then value.keys.map(&:to_s) - when Struct then value.members.map(&:to_s) - else raise NotImplementedError - end + if value.respond_to?(:to_hash) + value.to_hash.keys.map(&:to_s) + elsif value.is_a?(Struct) + value.members.map(&:to_s) else return maybe_raise Errors::InvalidTypeError, "function keys() expects a hash" end @@ -323,10 +337,12 @@ class ValuesFunction < Function def call(args) if args.count == 1 value = args.first - if hash_like?(value) + if value.respond_to?(:to_hash) + value.to_hash.values + elsif value.is_a?(Struct) value.values - elsif Array === value - value + elsif value.respond_to?(:to_ary) + value.to_ary else return maybe_raise Errors::InvalidTypeError, "function values() expects an array or a hash" end @@ -343,10 +359,10 @@ def call(args) if args.count == 2 glue = args[0] values = args[1] - if !(String === glue) + if !glue.respond_to?(:to_str) return maybe_raise Errors::InvalidTypeError, "function join() expects the first argument to be a string" - elsif Array === values && values.all? { |v| String === v } - values.join(glue) + elsif values.respond_to?(:to_ary) && values.to_ary.all? { |v| v.respond_to?(:to_str) } + values.to_ary.join(glue) else return maybe_raise Errors::InvalidTypeError, "function join() expects values to be an array of strings" end @@ -362,7 +378,7 @@ class ToStringFunction < Function def call(args) if args.count == 1 value = args.first - String === value ? value : value.to_json + value.respond_to?(:to_str) ? value.to_str : value.to_json else return maybe_raise Errors::InvalidArityError, "function to_string() expects one argument" end @@ -390,8 +406,8 @@ class SumFunction < Function FUNCTIONS['sum'] = self def call(args) - if args.count == 1 && Array === args.first - args.first.inject(0) do |sum,n| + if args.count == 1 && args.first.respond_to?(:to_ary) + args.first.to_ary.inject(0) do |sum,n| if Numeric === n sum + n else @@ -424,7 +440,8 @@ class SortFunction < Function def call(args) if args.count == 1 value = args.first - if Array === value + if value.respond_to?(:to_ary) + value = value.to_ary # every element in the list must be of the same type array_type = get_type(value[0]) if array_type == STRING_TYPE || array_type == NUMBER_TYPE || value.size == 0 @@ -459,7 +476,7 @@ class SortByFunction < Function def call(args) if args.count == 2 if get_type(args[0]) == ARRAY_TYPE && get_type(args[1]) == EXPRESSION_TYPE - values = args[0] + values = args[0].to_ary expression = args[1] array_type = get_type(expression.eval(values[0])) if array_type == STRING_TYPE || array_type == NUMBER_TYPE || values.size == 0 @@ -495,6 +512,7 @@ def compare_by(mode, *args) values = args[0] expression = args[1] if get_type(values) == ARRAY_TYPE && get_type(expression) == EXPRESSION_TYPE + values = values.to_ary type = get_type(expression.eval(values.first)) if type != NUMBER_TYPE && type != STRING_TYPE msg = "function #{mode}() expects values to be strings or numbers" @@ -616,8 +634,10 @@ def call(args) return maybe_raise Errors::InvalidArityError, msg end value = args.first - if Array === value || String === value - value.reverse + if value.respond_to?(:to_ary) + value.to_ary.reverse + elsif value.respond_to?(:to_str) + value.to_str.reverse else msg = "function reverse() expects an array or string" return maybe_raise Errors::InvalidTypeError, msg @@ -630,7 +650,7 @@ class ToArrayFunction < Function def call(args) value = args.first - Array === value ? value : [value] + value.respond_to?(:to_ary) ? value.to_ary : [value] end end end diff --git a/lib/jmespath/nodes/projection.rb b/lib/jmespath/nodes/projection.rb index ef4e737..bed2090 100644 --- a/lib/jmespath/nodes/projection.rb +++ b/lib/jmespath/nodes/projection.rb @@ -45,8 +45,8 @@ def visit(value) class ArrayProjection < Projection def extract_targets(target) - if Array === target - target + if target.respond_to?(:to_ary) + target.to_ary else nil end @@ -63,7 +63,9 @@ class FastArrayProjection < ArrayProjection class ObjectProjection < Projection def extract_targets(target) - if hash_like?(target) + if target.respond_to?(:to_hash) + target.to_hash.values + elsif target.is_a?(Struct) target.values else nil diff --git a/lib/jmespath/nodes/slice.rb b/lib/jmespath/nodes/slice.rb index 97f045f..2db7461 100644 --- a/lib/jmespath/nodes/slice.rb +++ b/lib/jmespath/nodes/slice.rb @@ -10,7 +10,7 @@ def initialize(start, stop, step) end def visit(value) - if String === value || Array === value + if (value = value.respond_to?(:to_str) ? value.to_str : value.respond_to?(:to_ary) ? value.to_ary : nil) start, stop, step = adjust_slice(value.size, @start, @stop, @step) result = [] if step > 0 @@ -26,7 +26,7 @@ def visit(value) i += step end end - String === value ? result.join : result + value.respond_to?(:to_str) ? result.join : result else nil end @@ -80,7 +80,7 @@ def initialize(start, stop) end def visit(value) - if String === value || Array === value + if (value = value.respond_to?(:to_str) ? value.to_str : value.respond_to?(:to_ary) ? value.to_ary : nil) value[@start, @stop - @start] else nil diff --git a/lib/jmespath/util.rb b/lib/jmespath/util.rb index 9605393..62f1af9 100644 --- a/lib/jmespath/util.rb +++ b/lib/jmespath/util.rb @@ -9,7 +9,9 @@ class << self # def falsey?(value) !value || - (value.respond_to?(:empty?) && value.empty?) || + (value.respond_to?(:to_ary) && value.to_ary.empty?) || + (value.respond_to?(:to_hash) && value.to_hash.empty?) || + (value.respond_to?(:to_str) && value.to_str.empty?) || (value.respond_to?(:entries) && !value.entries.any?) # final case necessary to support Enumerable and Struct end From 7018418d31e971ef75d64cc525eba0e77fd8791e Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 6 Jan 2022 23:19:30 -0800 Subject: [PATCH 4/6] add Util.as_json to recursively handle comparison of complex objects --- lib/jmespath/nodes/comparator.rb | 4 ++-- lib/jmespath/nodes/condition.rb | 8 ++++---- lib/jmespath/nodes/function.rb | 4 ++-- lib/jmespath/util.rb | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/jmespath/nodes/comparator.rb b/lib/jmespath/nodes/comparator.rb index 3108b8d..67dc33c 100644 --- a/lib/jmespath/nodes/comparator.rb +++ b/lib/jmespath/nodes/comparator.rb @@ -42,13 +42,13 @@ module Comparators class Eq < Comparator def check(left_value, right_value) - left_value == right_value + Util.as_json(left_value) == Util.as_json(right_value) end end class Neq < Comparator def check(left_value, right_value) - left_value != right_value + Util.as_json(left_value) != Util.as_json(right_value) end end diff --git a/lib/jmespath/nodes/condition.rb b/lib/jmespath/nodes/condition.rb index e467d78..3233821 100644 --- a/lib/jmespath/nodes/condition.rb +++ b/lib/jmespath/nodes/condition.rb @@ -43,7 +43,7 @@ class EqCondition < ComparatorCondition COMPARATOR_TO_CONDITION[Comparators::Eq] = self def visit(value) - @left.visit(value) == @right.visit(value) ? @child.visit(value) : nil + Util.as_json(@left.visit(value)) == Util.as_json(@right.visit(value)) ? @child.visit(value) : nil end def optimize @@ -62,7 +62,7 @@ def initialize(left, right, child) end def visit(value) - @left.visit(value) == @right ? @child.visit(value) : nil + Util.as_json(@left.visit(value)) == @right ? @child.visit(value) : nil end end @@ -70,7 +70,7 @@ class NeqCondition < ComparatorCondition COMPARATOR_TO_CONDITION[Comparators::Neq] = self def visit(value) - @left.visit(value) != @right.visit(value) ? @child.visit(value) : nil + Util.as_json(@left.visit(value)) != Util.as_json(@right.visit(value)) ? @child.visit(value) : nil end def optimize @@ -89,7 +89,7 @@ def initialize(left, right, child) end def visit(value) - @left.visit(value) != @right ? @child.visit(value) : nil + Util.as_json(@left.visit(value)) != @right ? @child.visit(value) : nil end end diff --git a/lib/jmespath/nodes/function.rb b/lib/jmespath/nodes/function.rb index 2531715..6dfd3d1 100644 --- a/lib/jmespath/nodes/function.rb +++ b/lib/jmespath/nodes/function.rb @@ -151,11 +151,11 @@ class ContainsFunction < Function def call(args) if args.count == 2 haystack = args[0] - needle = args[1] + needle = Util.as_json(args[1]) if haystack.respond_to?(:to_str) haystack.to_str.include?(needle) elsif haystack.respond_to?(:to_ary) - haystack.to_ary.include?(needle) + haystack.to_ary.any? { |e| Util.as_json(e) == needle } else return maybe_raise Errors::InvalidTypeError, "contains expects 2nd arg to be a list" end diff --git a/lib/jmespath/util.rb b/lib/jmespath/util.rb index 62f1af9..73ac86d 100644 --- a/lib/jmespath/util.rb +++ b/lib/jmespath/util.rb @@ -15,6 +15,20 @@ def falsey?(value) (value.respond_to?(:entries) && !value.entries.any?) # final case necessary to support Enumerable and Struct end + + def as_json(value) + if value.respond_to?(:to_ary) + value.to_ary.map { |e| as_json(e) } + elsif value.respond_to?(:to_hash) + hash = {} + value.to_hash.each_pair { |k, v| hash[k] = as_json(v) } + hash + elsif value.respond_to?(:to_str) + value.to_str + else + value + end + end end end end From 31b740f0887328f857940a8a2e602effcf9e4421 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 4 Jan 2022 23:55:16 -0800 Subject: [PATCH 5/6] test handling of objects which are implicitly convertible to hash or array --- spec/implicit_conversion_spec.rb | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 spec/implicit_conversion_spec.rb diff --git a/spec/implicit_conversion_spec.rb b/spec/implicit_conversion_spec.rb new file mode 100644 index 0000000..a08c993 --- /dev/null +++ b/spec/implicit_conversion_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +module Wrapper + def self.wrap(o) + o.respond_to?(:to_ary) ? Arrayish.new(o) : o.respond_to?(:to_hash) ? Hashish.new(o) : o + end +end + +class Arrayish + def initialize(ary) + @ary = ary.to_ary + end + + attr_reader :ary + + def to_ary + @ary.map { |e| Wrapper.wrap(e) } + end +end + +class Hashish + def initialize(hash) + @hash = hash.to_hash + end + + attr_reader :hash + + def to_hash + to_hash = {} + @hash.each_pair { |k, v| to_hash[k] = Wrapper.wrap(v) } + to_hash + end +end + +module JMESPath + describe '.search' do + describe 'implicit conversion' do + + it 'searches hash/array structures' do + data = Hashish.new({'foo' => {'bar' => ['value']}}) + result = JMESPath.search('foo.bar', data) + expect(result).to be_instance_of(Arrayish) + expect(result.ary).to eq(['value']) + end + + it 'searches with flatten' do + data = Hashish.new({'foo' => [[{'bar' => 0}], [{'baz' => 0}]]}) + result = JMESPath.search('foo[]', data) + expect(result.size).to eq(2) + expect(result[0]).to be_instance_of(Hashish) + expect(result[0].hash).to eq({'bar' => 0}) + expect(result[1]).to be_instance_of(Hashish) + expect(result[1].hash).to eq({'baz' => 0}) + end + + end + end +end From ef7a16e3bbae8f07518e8c143d3e5fbe6d1a64ce Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Wed, 5 Jan 2022 13:17:20 -0800 Subject: [PATCH 6/6] Add compliance specs to the implicit conversion --- spec/implicit_conversion_spec.rb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/implicit_conversion_spec.rb b/spec/implicit_conversion_spec.rb index a08c993..9366843 100644 --- a/spec/implicit_conversion_spec.rb +++ b/spec/implicit_conversion_spec.rb @@ -54,5 +54,33 @@ module JMESPath end end + + describe 'Compliance' do + Dir.glob('spec/{compliance,legacy}/*.json').each do |path| + + test_file = File.basename(path).split('.').first + next if test_file == 'benchmarks' + next if ENV['TEST_FILE'] && ENV['TEST_FILE'] != test_file + + describe(test_file) do + JMESPath.load_json(path).each do |scenario| + describe("Given #{scenario['given'].to_json}") do + scenario['cases'].each do |test_case| + + if !test_case['error'] + it "searching #{test_case['expression'].inspect} returns #{test_case['result'].to_json}" do + result = JMESPath.search(test_case['expression'], Wrapper.wrap(scenario['given'])) + + expect(JMESPath::Util.as_json(result)).to eq(test_case['result']) + end + + end + end + end + end + end + end + end + end end