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)