Skip to content

Commit

Permalink
Fix backward-compatibility issue with the 'sort' filter
Browse files Browse the repository at this point in the history
  • Loading branch information
karreiro committed Jan 29, 2025
1 parent 8dd9279 commit 0ae38ba
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 3 deletions.
4 changes: 4 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 5.8.0 (unreleased)

## 5.7.2 2025-01-30

- Fix the `sort` filter to handle nested properties gracefully when their types don't match

## 5.7.1 2025-01-24

* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays
Expand Down
34 changes: 32 additions & 2 deletions lib/liquid/standardfilters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,23 @@ def sort(input, property = nil)
end
elsif ary.all? { |el| el.respond_to?(:[]) }
begin
ary.sort { |a, b| nil_safe_compare(fetch_property(a, property), fetch_property(b, property)) }
ary.sort do |a, b|
a = fetch_property(a, property)
b = fetch_property(b, property)

##
# We handle nested properties gracefully to avoid breaking backward
# compatibility.
#
# However, we raise errors for incompatible types when no nested
# properties are used to maintain strict type checking in simple
# cases.
if has_nested_property?(property)
type_safe_compare(a, b) { |a, b| nil_safe_compare(a, b) }
else
nil_safe_compare(a, b)
end
end
rescue TypeError
raise_property_error(property)
end
Expand Down Expand Up @@ -1005,14 +1021,18 @@ def fetch_property(drop, property_or_keys)
# ```
value = drop[property_or_keys]

return value if !value.nil? || !property_or_keys.is_a?(String)
return value if !value.nil? || !has_nested_property?(property_or_keys)

keys = property_or_keys.split('.')
keys.reduce(drop) do |drop, key|
drop.respond_to?(:[]) ? drop[key] : drop
end
end

def has_nested_property?(property)
property.is_a?(String) && property.include?('.')
end

def raise_property_error(property)
raise Liquid::ArgumentError, "cannot select the property '#{property}'"
end
Expand All @@ -1036,6 +1056,16 @@ def nil_safe_compare(a, b)
end
end

def type_safe_compare(a, b)
klass_a = a.class
klass_b = b.class

# Converting classes to string to have a deterministic comparison.
return nil_safe_casecmp(klass_a, klass_b) if klass_a != klass_b

yield(a, b)
end

def nil_safe_casecmp(a, b)
if !a.nil? && !b.nil?
a.to_s.casecmp(b.to_s)
Expand Down
2 changes: 1 addition & 1 deletion lib/liquid/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# frozen_string_literal: true

module Liquid
VERSION = "5.7.1"
VERSION = "5.7.2"
end
88 changes: 88 additions & 0 deletions test/integration/standard_filter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,94 @@ def test_sum_with_deep_enumerables
assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new })
end

def test_sort_with_different_types
input = [
{ "price" => 1000 },
{ "price" => :none },
{ "price" => 3000 }
]

assert_raises(Liquid::ArgumentError) do
@filters.sort(input, "price")
end
end

def test_sort_with_nested_different_types
input = [
{ "price" => { "value" => 1000, "unit" => "BRL" } },
{ "price" => { "value" => 2000, "unit" => nil } },
{ "price" => { "value" => 3000, "unit" => :none } }
]
expected_output = "2000, 1000, 3000"
template = <<~LIQUID
{{- input | sort: 'price.unit' | map: 'price.value' | join: ', ' -}}
LIQUID

assert_template_result(expected_output, template, { "input" => input })
end

def test_sort_natural_with_nested_different_types
input = [
{ "price" => { "value" => 1000, "unit" => "brl" } },
{ "price" => { "value" => 2000, "unit" => "BRL" } },
{ "price" => { "value" => 3000, "unit" => nil } },
{ "price" => { "value" => 4000, "unit" => :brl } }
]
expected_output = "1000, 2000, 4000, 3000"
template = <<~LIQUID
{{- input | sort_natural: 'price.unit' | map: 'price.value' | join: ', ' -}}
LIQUID

assert_template_result(expected_output, template, { "input" => input })
end

def test_uniq_with_nested_different_types
input = [
{ "price" => { "value" => 1000, "unit" => "BRL" } },
{ "price" => { "value" => 2000, "unit" => "BRL" } },
{ "price" => { "value" => 3000, "unit" => :USD } },
{ "price" => { "value" => 4000, "unit" => :BRL } },
{ "price" => { "value" => 5000, "unit" => nil } }
]

expected_output = "BRL, USD, BRL, " # Uniq handles different types uniqueness
template = <<~LIQUID
{{- input | uniq: 'price.unit' | map: 'price.unit' | join: ', ' -}}
LIQUID

assert_template_result(expected_output, template, { "input" => input })
end

def test_map_with_nested_different_types
input = [
{ "price" => { "value" => 1000, "unit" => "brl" } },
{ "price" => { "value" => 2000, "unit" => "BRL" } },
{ "price" => { "value" => 3000, "unit" => nil } },
{ "price" => { "value" => 4000, "unit" => :brl } }
]
expected_output = "brl, BRL, , brl"
template = <<~LIQUID
{{- input | map: 'price.unit'| join: ', ' -}}
LIQUID

assert_template_result(expected_output, template, { "input" => input })
end

def test_sum_with_nested_different_types
input = [
{ "price" => { "value" => 1000 } },
{ "price" => { "value" => nil } },
{ "price" => { "value" => :none } },
{ "price" => { "value" => 3000 } }
]
expected_output = "4000"
template = <<~LIQUID
{{- input | sum: 'price.value' -}}
LIQUID

assert_template_result(expected_output, template, { "input" => input })
end

private

def with_timezone(tz)
Expand Down

0 comments on commit 0ae38ba

Please sign in to comment.