diff --git a/Gemfile b/Gemfile index 509316c1a..d7c005fff 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ end gemspec gem "base64" +gem "webrick" group :benchmark, :test do gem 'benchmark-ips' diff --git a/example/server/example_servlet.rb b/example/server/example_servlet.rb index d2d4fd6a7..30cb3ee16 100644 --- a/example/server/example_servlet.rb +++ b/example/server/example_servlet.rb @@ -20,7 +20,7 @@ def paragraph(p) class Servlet < LiquidServlet def index - { 'date' => Time.now } + { 'date' => Time.now, 'products' => products_list } end def products @@ -29,11 +29,47 @@ def products private + class Name < Liquid::Drop + attr_reader :raw, :origin + + def initialize(raw, origin) + super() + @raw = raw + @origin = origin + end + end + + class Price < Liquid::Drop + attr_reader :value, :unit + + def initialize(value, unit = 'USD') + super() + @value = value + @unit = unit + end + end + + class Product < Liquid::Drop + attr_reader :name, :price, :description + + def initialize(name, origin, price, description) + super() + @name = Name.new(name, origin) + @price = Price.new(price) + @description = description + end + end + def products_list + # [ + # Product.new('Draft', 'Arbor', 39900, 'the *arbor draft* is a excellent product'), + # Product.new('Element', 'Arbor', 40000, 'the *arbor element* rocks for freestyling'), + # Product.new('Diamond', 'Arbor', 59900, 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity') + # ] [ - { 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, - { 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' }, - { 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' } + { 'name' => 'Arbor 2Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, + { 'name' => 'Arbor 2Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' }, + { 'name' => 'Arbor 2Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' } ] end diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 4872aa845..a326ceee5 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -2,5 +2,11 @@

It is {{date}}

+
-

Check out the Products screen

+{{ products.size }} + + +

+ +{{ products | sum: 'price.value' }} diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index ffaceb32f..c6f529745 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -378,7 +378,7 @@ def sort(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - ary.sort { |a, b| nil_safe_compare(a[property], b[property]) } + ary.sort { |a, b| nil_safe_compare(fetch_property(a, property), fetch_property(b, property)) } rescue TypeError raise_property_error(property) end @@ -407,7 +407,7 @@ def sort_natural(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) } + ary.sort { |a, b| nil_safe_casecmp(fetch_property(a, property), fetch_property(b, property)) } rescue TypeError raise_property_error(property) end @@ -424,29 +424,59 @@ def sort_natural(input, property = nil) # @liquid_syntax array | where: string, string # @liquid_return [array[untyped]] def where(input, property, target_value = nil) - ary = InputIterator.new(input, context) + filter_array(input, property, target_value, :select) + end - if ary.empty? - [] - elsif target_value.nil? - ary.select do |item| - item[property] - rescue TypeError - raise_property_error(property) - rescue NoMethodError - return nil unless item.respond_to?(:[]) - raise - end - else - ary.select do |item| - item[property] == target_value - rescue TypeError - raise_property_error(property) - rescue NoMethodError - return nil unless item.respond_to?(:[]) - raise - end - end + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Filters an array to exclude items with a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | reject: string, string + # @liquid_return [array[untyped]] + def reject(input, property, target_value = nil) + filter_array(input, property, target_value, :reject) + end + + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Tests if any item in an array has a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | some: string, string + # @liquid_return [boolean] + def has(input, property, target_value = nil) + filter_array(input, property, target_value, :any?) + end + + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Returns the first item in an array with a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | find: string, string + # @liquid_return [untyped] + def find(input, property, target_value = nil) + filter_array(input, property, target_value, :find) + end + + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Returns the index of the first item in an array with a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | find_index: string, string + # @liquid_return [number] + def find_index(input, property, target_value = nil) + filter_array(input, property, target_value, :find_index) end # @liquid_public_docs @@ -465,7 +495,7 @@ def uniq(input, property = nil) [] else ary.uniq do |item| - item[property] + fetch_property(item, property) rescue TypeError raise_property_error(property) rescue NoMethodError @@ -501,7 +531,7 @@ def map(input, property) if property == "to_liquid" e elsif e.respond_to?(:[]) - r = e[property] + r = fetch_property(e, property) r.is_a?(Proc) ? r.call : r end end @@ -525,7 +555,7 @@ def compact(input, property = nil) [] else ary.reject do |item| - item[property].nil? + fetch_property(item, property).nil? rescue TypeError raise_property_error(property) rescue NoMethodError @@ -899,7 +929,7 @@ def sum(input, property = nil) if property.nil? item elsif item.respond_to?(:[]) - item[property] + fetch_property(item, property) else 0 end @@ -918,6 +948,40 @@ def sum(input, property = nil) attr_reader :context + def filter_array(input, property, target_value, method) + ary = InputIterator.new(input, context) + + return [] if ary.empty? + + ary.public_send(method) do |item| + if target_value.nil? + fetch_property(item, property) + else + fetch_property(item, property) == target_value + end + rescue TypeError + raise_property_error(property) + rescue NoMethodError + return nil unless item.respond_to?(:[]) + raise + end + end + + def fetch_property(drop, property_or_keys) + # For backward compatibility - historically some drops may have + # properties containing dots in their names (e.g. "foo.bar"). + # We first attempt to look up the exact key before falling + # back to treating dots as nested property access. + value = drop[property_or_keys] + + return value if !value.nil? || !property_or_keys.is_a?(String) + + keys = property_or_keys.split('.') + keys.reduce(drop) do |drop, key| + drop.respond_to?(:[]) ? drop[key] : drop + end + end + def raise_property_error(property) raise Liquid::ArgumentError, "cannot select the property '#{property}'" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index eae4a1c9c..2b8c0fde6 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -54,6 +54,30 @@ def each(&block) end end +class TestDeepEnumerable < Liquid::Drop + include Enumerable + + class Product < Liquid::Drop + attr_reader :title, :price, :premium + + def initialize(title:, price:, premium: nil) + @title = { "content" => title, "language" => "en" } + @price = { "value" => price, "unit" => "USD" } + @premium = { "category" => premium } if premium + end + end + + def each(&block) + [ + Product.new(title: "Pro goggles", price: 1299), + Product.new(title: "Thermal gloves", price: 1299), + Product.new(title: "Alpine jacket", price: 3999, premium: 'Basic'), + Product.new(title: "Mountain boots", price: 3899, premium: 'Pro'), + Product.new(title: "Safety helmet", price: 1999) + ].each(&block) + end +end + class NumberLikeThing < Liquid::Drop def initialize(amount) @amount = amount @@ -392,6 +416,15 @@ def test_sort_natural_invalid_property end end + def test_sort_natural_with_deep_enumerables + template = <<~LIQUID + {{- products | sort_natural: 'title.content' | map: 'title.content' | join: ', ' -}} + LIQUID + expected_output = "Alpine jacket, Mountain boots, Pro goggles, Safety helmet, Thermal gloves" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_legacy_sort_hash assert_equal([{ a: 1, b: 2 }], @filters.sort(a: 1, b: 2)) end @@ -428,6 +461,15 @@ def test_uniq_invalid_property end end + def test_uniq_with_deep_enumerables + template = <<~LIQUID + {{- products | uniq: 'price.value' | map: "title.content" | join: ', ' -}} + LIQUID + expected_output = "Pro goggles, Alpine jacket, Mountain boots, Safety helmet" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_compact_empty_array assert_equal([], @filters.compact([], "a")) end @@ -444,6 +486,15 @@ def test_compact_invalid_property end end + def test_compact_with_deep_enumerables + template = <<~LIQUID + {{- products | compact: 'premium.category' | map: 'title.content' | join: ', ' -}} + LIQUID + expected_output = "Alpine jacket, Mountain boots" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_reverse assert_equal([4, 3, 2, 1], @filters.reverse([1, 2, 3, 4])) end @@ -553,6 +604,16 @@ def test_sort_works_on_enumerables assert_template_result("213", '{{ foo | sort: "bar" | map: "foo" }}', { "foo" => TestEnumerable.new }) end + def test_sort_with_deep_enumerables + + template = <<~LIQUID + {{- products | sort: 'price.value' | map: 'title.content' | join: ', ' -}} + LIQUID + expected_output = "Pro goggles, Thermal gloves, Safety helmet, Mountain boots, Alpine jacket" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_first_and_last_call_to_liquid assert_template_result('foobar', '{{ foo | first }}', { 'foo' => [ThingWithToLiquid.new] }) assert_template_result('foobar', '{{ foo | last }}', { 'foo' => [ThingWithToLiquid.new] }) @@ -827,21 +888,219 @@ def test_date_raises_nothing assert_template_result('abc', "{{ 'abc' | date: '%D' }}") end + def test_reject + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}" + expected_output = "beta gamma" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_reject_with_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}" + expected_output = "beta gamma" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_reject_with_false_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_reject_with_deep_enumerables + template = <<~LIQUID + {{- products | reject: 'title.content', 'Pro goggles' | map: 'price.value' | join: ', ' -}} + LIQUID + expected_output = "1299, 3999, 3899, 1999" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + + def test_has + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => false }, + ] + + expected_output = "true" + + assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array }) + assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) + end + + def test_has_when_does_not_have_it + array = [ + { "handle" => "alpha", "ok" => false }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => false }, + ] + + expected_output = "false" + + assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array }) + assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) + end + + def test_has_with_false_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | has: 'ok', false }}" + expected_output = "true" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_has_with_false_value_when_does_not_have_it + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => true }, + { "handle" => "gamma", "ok" => true }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | has: 'ok', false }}" + expected_output = "false" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_has_with_deep_enumerables + template = <<~LIQUID + {{- products | has: 'title.content', 'Pro goggles' -}}, + {{- products | has: 'title.content', 'foo' -}} + LIQUID + expected_output = "true,false" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + + def test_find_with_value + products = [ + { "title" => "Pro goggles", "price" => 1299 }, + { "title" => "Thermal gloves", "price" => 1499 }, + { "title" => "Alpine jacket", "price" => 3999 }, + { "title" => "Mountain boots", "price" => 3899 }, + { "title" => "Safety helmet", "price" => 1999 } + ] + + template = <<~LIQUID + {%- assign product = products | find: 'price', 3999 -%} + {{- product.title -}} + LIQUID + expected_output = "Alpine jacket" + + assert_template_result(expected_output, template, { "products" => products }) + end + + def test_find_with_deep_enumerables + template = <<~LIQUID + {%- assign product = products | find: 'title.content', 'Pro goggles' -%} + {{- product.title.content -}} + LIQUID + expected_output = "Pro goggles" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + + def test_find_index_with_value + products = [ + { "title" => "Pro goggles", "price" => 1299 }, + { "title" => "Thermal gloves", "price" => 1499 }, + { "title" => "Alpine jacket", "price" => 3999 }, + { "title" => "Mountain boots", "price" => 3899 }, + { "title" => "Safety helmet", "price" => 1999 } + ] + + template = <<~LIQUID + {%- assign index = products | find_index: 'price', 3999 -%} + {{- index -}} + LIQUID + expected_output = "2" + + assert_template_result(expected_output, template, { "products" => products }) + end + + def test_find_index_with_deep_enumerables + template = <<~LIQUID + {%- assign index = products | find_index: 'title.content', 'Alpine jacket' -%} + {{- index -}} + LIQUID + expected_output = "2" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_where - input = [ + array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] - expectation = [ + template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_where_with_value + array = [ { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] - assert_equal(expectation, @filters.where(input, "ok", true)) - assert_equal(expectation, @filters.where(input, "ok")) + template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_where_with_false_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}" + expected_output = "beta gamma" + + assert_template_result(expected_output, template, { "array" => array }) end def test_where_string_keys @@ -900,6 +1159,15 @@ def test_where_array_of_only_unindexable_values assert_nil(@filters.where([nil], "ok")) end + def test_where_with_deep_enumerables + template = <<~LIQUID + {{- products | where: 'title.content', 'Pro goggles' | map: 'price.value' -}} + LIQUID + expected_output = "1299" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_all_filters_never_raise_non_liquid_exception test_drop = TestDrop.new(value: "test") test_drop.context = Context.new @@ -1051,6 +1319,15 @@ def test_sum_with_floats_and_indexable_map_values assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input }) end + def test_sum_with_deep_enumerables + template = <<~LIQUID + {{- products | sum: 'price.value' -}} + LIQUID + expected_output = "12495" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + private def with_timezone(tz)