Add deep search for filter taking in properties #1749

This commit is contained in:
Anders Søgaard
2024-12-03 14:11:15 +01:00
committed by Guilherme Carreiro
parent 4b01977067
commit 1058a7768f
2 changed files with 365 additions and 18 deletions
+72 -18
View File
@@ -367,7 +367,10 @@ module Liquid
# Sorts the items in an array in case-sensitive alphabetical, or numerical, order.
# @liquid_syntax array | sort
# @liquid_return [array[untyped]]
def sort(input, property = nil)
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def sort(input, property = nil, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
ary = InputIterator.new(input, context)
return [] if ary.empty?
@@ -378,7 +381,13 @@ module Liquid
end
elsif ary.all? { |el| el.respond_to?(:[]) }
begin
ary.sort { |a, b| nil_safe_compare(a[property], b[property]) }
ary.sort do |a, b|
if deep[:enable]
nil_safe_compare(a.dig(*deep[:properties]), b.dig(*deep[:properties]))
else
nil_safe_compare(a[property], b[property])
end
end
rescue TypeError
raise_property_error(property)
end
@@ -396,7 +405,10 @@ module Liquid
# > string, so sorting on numerical values can lead to unexpected results.
# @liquid_syntax array | sort_natural
# @liquid_return [array[untyped]]
def sort_natural(input, property = nil)
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def sort_natural(input, property = nil, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
ary = InputIterator.new(input, context)
return [] if ary.empty?
@@ -407,7 +419,13 @@ module Liquid
end
elsif ary.all? { |el| el.respond_to?(:[]) }
begin
ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) }
ary.sort do |a, b|
if deep[:enable]
nil_safe_casecmp(a.dig(*deep[:properties]), b.dig(*deep[:properties]))
else
nil_safe_casecmp(a[property], b[property])
end
end
rescue TypeError
raise_property_error(property)
end
@@ -423,7 +441,10 @@ module Liquid
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | where: string, string
# @liquid_return [array[untyped]]
def where(input, property, target_value = nil)
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def where(input, property, target_value = nil, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
ary = InputIterator.new(input, context)
if ary.empty?
@@ -439,7 +460,8 @@ module Liquid
end
else
ary.select do |item|
item[property] == target_value
item_value = deep[:enable] ? item.dig(*deep[:properties]) : item[property]
item_value == target_value
rescue TypeError
raise_property_error(property)
rescue NoMethodError
@@ -457,6 +479,7 @@ module Liquid
# Removes any duplicate items in an array.
# @liquid_syntax array | uniq
# @liquid_return [array[untyped]]
<<<<<<< HEAD
=======
# Reject the elements of an array to those with a certain property value.
# By default the target is any falsy value.
@@ -485,6 +508,12 @@ module Liquid
# provide optional property with which to determine uniqueness
>>>>>>> bfdcfcea (Add reject filter)
def uniq(input, property = nil)
=======
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def uniq(input, property = nil, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
>>>>>>> 58d7d1a8 (Add deep search for suitable filters)
ary = InputIterator.new(input, context)
if property.nil?
@@ -493,7 +522,7 @@ module Liquid
[]
else
ary.uniq do |item|
item[property]
deep[:enable] ? item.dig(*deep[:properties]) : item[property]
rescue TypeError
raise_property_error(property)
rescue NoMethodError
@@ -522,15 +551,19 @@ module Liquid
# Creates an array of values from a specific property of the items in an array.
# @liquid_syntax array | map: string
# @liquid_return [array[untyped]]
def map(input, property)
InputIterator.new(input, context).map do |e|
e = e.call if e.is_a?(Proc)
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def map(input, property, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
InputIterator.new(input, context).map do |item|
item = item.call if item.is_a?(Proc)
if property == "to_liquid"
e
elsif e.respond_to?(:[])
r = e[property]
r.is_a?(Proc) ? r.call : r
item
elsif item.respond_to?(:[])
result = deep[:enable] ? item.dig(*deep[:properties]) : item[property]
result.is_a?(Proc) ? result.call : result
end
end
rescue TypeError
@@ -544,7 +577,10 @@ module Liquid
# Removes any `nil` items from an array.
# @liquid_syntax array | compact
# @liquid_return [array[untyped]]
def compact(input, property = nil)
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def compact(input, property = nil, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
ary = InputIterator.new(input, context)
if property.nil?
@@ -553,7 +589,7 @@ module Liquid
[]
else
ary.reject do |item|
item[property].nil?
deep[:enable] ? item.dig(*deep[:properties]).nil? : item[property].nil?
rescue TypeError
raise_property_error(property)
rescue NoMethodError
@@ -963,7 +999,11 @@ module Liquid
# Returns the sum of all elements in an array.
# @liquid_syntax array | sum
# @liquid_return [number]
def sum(input, property = nil)
# @liquid_optional_param deep [boolean | string] Whether to use dot notation to perform a deep search. A string can be passed to change separator.
def sum(input, property = nil, options = {})
options = {} unless options.is_a?(Hash)
deep = deep_search_properties(property, options)
ary = InputIterator.new(input, context)
return 0 if ary.empty?
@@ -971,7 +1011,7 @@ module Liquid
if property.nil?
item
elsif item.respond_to?(:[])
item[property]
deep[:enable] ? item.dig(*deep[:properties]) : item[property]
else
0
end
@@ -1023,6 +1063,20 @@ module Liquid
end
end
def deep_search_properties(key, options = {})
options = {} unless options.is_a?(Hash)
enable = options['deep'] ? true : false
separator = options['deep'].is_a?(String) ? options['deep'] : '.' if enable
properties = key.to_s.split(separator) if enable
{
enable: enable,
separator: separator,
properties: properties,
}
end
class InputIterator
include Enumerable
+293
View File
@@ -286,6 +286,60 @@ class StandardFiltersTest < Minitest::Test
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
end
def test_sort_deep_default_separator
input = [
{ "foo" => { "price" => 4, "handle" => "alpha" } },
{ "foo" => { "handle" => "beta" } },
{ "foo" => { "price" => 1, "handle" => "gamma" } },
{ "foo" => { "handle" => "delta" } },
{ "foo" => { "price" => 2, "handle" => "epsilon" } },
]
expectation = [
{ "foo" => { "price" => 1, "handle" => "gamma" } },
{ "foo" => { "price" => 2, "handle" => "epsilon" } },
{ "foo" => { "price" => 4, "handle" => "alpha" } },
{ "foo" => { "handle" => "beta" } },
{ "foo" => { "handle" => "delta" } },
]
assert_equal(expectation, @filters.sort(input, "foo.price", { "deep" => true }))
end
def test_sort_deep_custom_separator
input = [
{ "foo" => { "price" => 4, "handle" => "alpha" } },
{ "foo" => { "handle" => "beta" } },
{ "foo" => { "price" => 1, "handle" => "gamma" } },
{ "foo" => { "handle" => "delta" } },
{ "foo" => { "price" => 2, "handle" => "epsilon" } },
]
expectation = [
{ "foo" => { "price" => 1, "handle" => "gamma" } },
{ "foo" => { "price" => 2, "handle" => "epsilon" } },
{ "foo" => { "price" => 4, "handle" => "alpha" } },
{ "foo" => { "handle" => "beta" } },
{ "foo" => { "handle" => "delta" } },
]
assert_equal(expectation, @filters.sort(input, "foo_price", { "deep" => "_" }))
end
def test_sort_deep_off_by_default
input = [
{ "foo.price" => 4, "handle" => "alpha" },
{ "handle" => "beta" },
{ "foo.price" => 1, "handle" => "gamma" },
{ "handle" => "delta" },
{ "foo.price" => 2, "handle" => "epsilon" },
]
expectation = [
{ "foo.price" => 1, "handle" => "gamma" },
{ "foo.price" => 2, "handle" => "epsilon" },
{ "foo.price" => 4, "handle" => "alpha" },
{ "handle" => "beta" },
{ "handle" => "delta" },
]
assert_equal(expectation, @filters.sort(input, "foo.price"))
end
def test_sort_with_nils
assert_equal([1, 2, 3, 4, nil], @filters.sort([nil, 4, 3, 2, 1]))
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }, {}], @filters.sort([{ "a" => 4 }, { "a" => 3 }, {}, { "a" => 1 }, { "a" => 2 }], "a"))
@@ -360,6 +414,72 @@ class StandardFiltersTest < Minitest::Test
assert_equal(["a", "b", "c", "X", "Y", "Z"], @filters.sort_natural(["X", "Y", "Z", "a", "b", "c"]))
end
def test_sort_natural_deep_default_separator
input = [
{ "foo" => { "key" => "X" } },
{ "foo" => { "key" => "Y" } },
{ "foo" => { "key" => "Z" } },
{ "foo" => { "fake" => "t" } },
{ "foo" => { "key" => "a" } },
{ "foo" => { "key" => "b" } },
{ "foo" => { "key" => "c" } },
]
expectation = [
{ "foo" => { "key" => "a" } },
{ "foo" => { "key" => "b" } },
{ "foo" => { "key" => "c" } },
{ "foo" => { "key" => "X" } },
{ "foo" => { "key" => "Y" } },
{ "foo" => { "key" => "Z" } },
{ "foo" => { "fake" => "t" } },
]
assert_equal(expectation, @filters.sort_natural(input, "foo.key", { "deep" => true }))
end
def test_sort_natural_deep_custom_separator
input = [
{ "foo" => { "key" => "X" } },
{ "foo" => { "key" => "Y" } },
{ "foo" => { "key" => "Z" } },
{ "foo" => { "fake" => "t" } },
{ "foo" => { "key" => "a" } },
{ "foo" => { "key" => "b" } },
{ "foo" => { "key" => "c" } },
]
expectation = [
{ "foo" => { "key" => "a" } },
{ "foo" => { "key" => "b" } },
{ "foo" => { "key" => "c" } },
{ "foo" => { "key" => "X" } },
{ "foo" => { "key" => "Y" } },
{ "foo" => { "key" => "Z" } },
{ "foo" => { "fake" => "t" } },
]
assert_equal(expectation, @filters.sort_natural(input, "foo_key", { "deep" => "_" }))
end
def test_sort_natural_deep_off_by_default
input = [
{ "foo.key" => "X" },
{ "foo.key" => "Y" },
{ "foo.key" => "Z" },
{ "foo.fake" => "t" },
{ "foo.key" => "a" },
{ "foo.key" => "b" },
{ "foo.key" => "c" },
]
expectation = [
{ "foo.key" => "a" },
{ "foo.key" => "b" },
{ "foo.key" => "c" },
{ "foo.key" => "X" },
{ "foo.key" => "Y" },
{ "foo.key" => "Z" },
{ "foo.fake" => "t" },
]
assert_equal(expectation, @filters.sort_natural(input, "foo.key"))
end
def test_sort_empty_array
assert_equal([], @filters.sort([], "a"))
end
@@ -428,6 +548,45 @@ class StandardFiltersTest < Minitest::Test
end
end
def test_uniq_deep_default_separator
input = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "baz", "handle" => "beta" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
expectation = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
assert_equal(expectation, @filters.uniq(input, "foo.bar", { "deep" => true }))
end
def test_uniq_deep_custom_separator
input = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "baz", "handle" => "beta" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
expectation = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
assert_equal(expectation, @filters.uniq(input, "foo_bar", { "deep" => "_" }))
end
def test_uniq_deep_off_by_default
input = [
{ "foo.bar" => "baz", "handle" => "alpha" },
{ "foo.bar" => "baz", "handle" => "beta" },
{ "foo.bar" => "qux", "handle" => "charlie" },
]
expectation = [
{ "foo.bar" => "baz", "handle" => "alpha" },
{ "foo.bar" => "qux", "handle" => "charlie" },
]
assert_equal(expectation, @filters.uniq(input, "foo.bar"))
end
def test_compact_empty_array
assert_equal([], @filters.compact([], "a"))
end
@@ -444,6 +603,45 @@ class StandardFiltersTest < Minitest::Test
end
end
def test_compact_deep_default_separator
input = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "handle" => "beta" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
expectation = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
assert_equal(expectation, @filters.compact(input, "foo.bar", { "deep" => true }))
end
def test_compact_deep_custom_separator
input = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "handle" => "beta" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
expectation = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
assert_equal(expectation, @filters.compact(input, "foo_bar", { "deep" => "_" }))
end
def test_compact_deep_off_by_default
input = [
{ "foo.bar" => "baz", "handle" => "alpha" },
{ "foo.handle" => "beta" },
{ "foo.bar" => "qux", "handle" => "charlie" },
]
expectation = [
{ "foo.bar" => "baz", "handle" => "alpha" },
{ "foo.bar" => "qux", "handle" => "charlie" },
]
assert_equal(expectation, @filters.compact(input, "foo.bar"))
end
def test_reverse
assert_equal([4, 3, 2, 1], @filters.reverse([1, 2, 3, 4]))
end
@@ -461,6 +659,30 @@ class StandardFiltersTest < Minitest::Test
)
end
def test_map_deep_default_separator
assert_template_result(
'abc',
"{{ ary | map: 'foo.bar', deep: true }}",
{ 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] },
)
end
def test_map_deep_custom_separator
assert_template_result(
'abc',
"{{ ary | map: 'foo_bar', deep: '_' }}",
{ 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] },
)
end
def test_map_deep_off_by_default
assert_template_result(
'abc',
"{{ ary | map: 'foo.bar' }}",
{ 'ary' => [{ 'foo.bar' => 'a' }, { 'foo.bar' => 'b' }, { 'foo.bar' => 'c' }] },
)
end
def test_map_doesnt_call_arbitrary_stuff
assert_template_result("", '{{ "foo" | map: "__id__" }}')
assert_template_result("", '{{ "foo" | map: "inspect" }}')
@@ -972,6 +1194,7 @@ class StandardFiltersTest < Minitest::Test
assert_nil(@filters.where([nil], "ok"))
end
<<<<<<< HEAD
def test_reject
input = [
{ "handle" => "alpha", "ok" => true },
@@ -1045,6 +1268,45 @@ class StandardFiltersTest < Minitest::Test
assert_equal(expectation, @filters.reject(input, "item.ok", false))
assert_equal(expectation, @filters.reject(input, "item.ok"))
=======
def test_where_deep_default_separator
input = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "baz", "handle" => "beta" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
expectation = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "baz", "handle" => "beta" } },
]
assert_equal(expectation, @filters.where(input, "foo.bar", "baz", { "deep" => true }))
end
def test_where_deep_custom_separator
input = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "baz", "handle" => "beta" } },
{ "foo" => { "bar" => "qux", "handle" => "charlie" } },
]
expectation = [
{ "foo" => { "bar" => "baz", "handle" => "alpha" } },
{ "foo" => { "bar" => "baz", "handle" => "beta" } },
]
assert_equal(expectation, @filters.where(input, "foo_bar", "baz", { "deep" => "_" }))
end
def test_where_deep_off_by_default
input = [
{ "foo.bar" => "baz", "handle" => "alpha" },
{ "foo.bar" => "baz", "handle" => "beta" },
{ "foo.bar" => "qux", "handle" => "charlie" },
]
expectation = [
{ "foo.bar" => "baz", "handle" => "alpha" },
{ "foo.bar" => "baz", "handle" => "beta" },
]
assert_equal(expectation, @filters.where(input, "foo.bar", "baz"))
>>>>>>> 2800d8dc (Add missing tests)
end
def test_all_filters_never_raise_non_liquid_exception
@@ -1182,6 +1444,7 @@ class StandardFiltersTest < Minitest::Test
assert(t.foo > 0)
end
<<<<<<< HEAD
def test_sum_of_floats
input = [0.1, 0.2, 0.3]
assert_equal(0.6, @filters.sum(input))
@@ -1216,6 +1479,36 @@ class StandardFiltersTest < Minitest::Test
assert_template_result("1.2", "{{ input | sum: 'quantity' }}", { "input" => input })
assert_template_result("0.1", "{{ input | sum: 'weight' }}", { "input" => input })
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
=======
def test_sum_deep_default_separator
input = [
{ "foo" => { "quantity" => 1 } },
{ "foo" => { "quantity" => 2 } },
{ "foo" => { "quantity" => 3 } },
{ "foo" => { "quantity" => 4 } },
]
assert_equal(10, @filters.sum(input, "foo.quantity", { "deep" => true }))
end
def test_sum_deep_custom_separator
input = [
{ "foo" => { "quantity" => 1 } },
{ "foo" => { "quantity" => 2 } },
{ "foo" => { "quantity" => 3 } },
{ "foo" => { "quantity" => 4 } },
]
assert_equal(10, @filters.sum(input, "foo_quantity", { "deep" => "_" }))
end
def test_sum_deep_off_by_default
input = [
{ "foo.quantity" => 1 },
{ "foo.quantity" => 2 },
{ "foo.quantity" => 3 },
{ "foo.quantity" => 4 },
]
assert_equal(10, @filters.sum(input, "foo.quantity"))
>>>>>>> 858428f2 (Initial new tests)
end
private