From 41a77c68c6b24a3b91aa840f158ab86ab807724f Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 3 Dec 2024 13:57:31 +0100 Subject: [PATCH] Add `find`, `find_index`, `has`, and `reject` filters to arrays --- lib/liquid/standardfilters.rb | 130 ++++++++--- test/integration/standard_filter_test.rb | 284 ++++++++++++++++++++++- 2 files changed, 382 insertions(+), 32 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index ffaceb32f..8f06f3542 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,50 @@ 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) + ## + # This keeps backward compatibility by supporting properties containing + # dots. This is valid in Liquid syntax and used in some runtimes, such as + # Shopify with metafields. + # + # Using this approach, properties like 'price.value' can be accessed in + # both of the following examples: + # + # ``` + # [ + # { 'name' => 'Item 1', 'price.price' => 40000 }, + # { 'name' => 'Item 2', 'price' => { 'value' => 39900 } } + # ] + # ``` + 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..e4eb3bec6 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,15 @@ 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 +887,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 +1158,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 +1318,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)