diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 6e072fcf5..3a030e7be 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -13,6 +13,9 @@ module StandardFilters I64_RANGE = MIN_I64..MAX_I64 private_constant :MIN_I64, :MAX_I64, :I64_RANGE + INFINITY_ARRAY = [Float::INFINITY].freeze + private_constant :INFINITY_ARRAY + HTML_ESCAPE = { '&' => '&', '>' => '>', @@ -422,6 +425,31 @@ def sort_natural(input, property = nil) end end + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Sorts an array by numerical values extracted from runs of digits within each item. + # @liquid_syntax array | sort_numeric + # @liquid_return [array[untyped]] + def sort_numeric(input, property = nil) + ary = InputIterator.new(input, context) + + return [] if ary.empty? + + if property.nil? + ary.sort do |a, b| + numeric_compare(a, b) + end + elsif ary.all? { |el| el.respond_to?(:[]) } + begin + ary.sort { |a, b| numeric_compare(a[property], b[property]) } + rescue TypeError + raise_property_error(property) + end + end + end + # @liquid_public_docs # @liquid_type filter # @liquid_category array @@ -1029,6 +1057,21 @@ def nil_safe_casecmp(a, b) end end + def numeric_compare(a, b) + numbers(a) <=> numbers(b) + end + + def numbers(obj) + if obj.is_a?(Numeric) + [obj] + else + numeric = obj.to_s.scan(/(?<=\.)0+|-?\d+/) + return INFINITY_ARRAY if numeric.empty? + + numeric.map(&:to_i) + end + end + class InputIterator include Enumerable diff --git a/test/integration/filter_test.rb b/test/integration/filter_test.rb index 133b57b3b..a974cb4d8 100644 --- a/test/integration/filter_test.rb +++ b/test/integration/filter_test.rb @@ -102,6 +102,32 @@ def test_sort_natural assert_equal('A b C', Template.parse("{{objects | sort_natural: 'a' | map: 'a' | join}}").render(@context)) end + def test_sort_numeric + assert_template_result( + "1 2 3 10", + "{{ array | sort_numeric | join }}", + { "array" => ["10", "3", "1", "2"] }, + ) + + assert_template_result( + "0001 02 17 042 107", + "{{ array | sort_numeric | join }}", + { "array" => ["107", "042", "0001", "02", "17"] }, + ) + + assert_template_result( + "1 2 10", + "{{ hash | sort_numeric: 'a' | map: 'a' | join }}", + { "hash" => [{ "a" => "10" }, { "a" => 1 }, { "a" => "2" }] }, + ) + + assert_template_result( + "v0.1 v1.1.0 v1.10", + "{{ objects | sort_numeric: 'a' | map: 'a' | join }}", + { "objects" => [TestObject.new("v0.1"), TestObject.new("v1.10"), TestObject.new("v1.1.0")] }, + ) + end + def test_compact # Test strings assert_template_result( diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index e1370fae1..d9ad90f64 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -425,6 +425,50 @@ def test_numerical_vs_lexicographical_sort assert_equal([{ "a" => "10" }, { "a" => "2" }], @filters.sort([{ "a" => "10" }, { "a" => "2" }], "a")) end + def test_sort_numeric + assert_equal([], @filters.sort_numeric([])) + assert_equal([1, 2, 3, 10], @filters.sort_numeric([10, 3, 1, 2])) + assert_equal(["1", "2", "3", "10"], @filters.sort_numeric(["10", "3", "1", "2"])) + assert_equal([1.01, 1.1, 2.3, 3.5, 10.1], @filters.sort_numeric([10.1, 3.5, 2.3, 1.1, 1.01])) + assert_equal(["1.01", "1.1", "2.3", "3.5", "10.1"], @filters.sort_numeric(["10.1", "3.5", "2.3", "1.1", "1.01"])) + assert_equal(["-1", "1"], @filters.sort_numeric(["1", "-1"])) + assert_equal(["1", "2", "3", "10", nil], @filters.sort_numeric(["10", "3", nil, "1", "2"])) + assert_equal(["v0.1", "v1.1.0", "v1.2", "v1.9", "v1.10", "v10.0"], @filters.sort_numeric(["v1.2", "v1.9", "v10.0", "v0.1", "v1.10", "v1.1.0"])) + assert_equal(["0001", "02", "17", "042", "107"], @filters.sort_numeric(["107", "042", "0001", "02", "17"])) + assert_equal(["7 The Street", "42 The Street", "101 The Street"], @filters.sort_numeric(["42 The Street", "7 The Street", "101 The Street"])) + assert_equal(["1", "2", "no numbers"], @filters.sort_numeric(["no numbers", "1", "2"])) + assert_equal(["1", 2, 3, "10"], @filters.sort_numeric(["10", 3, "1", 2])) + assert_equal([1, 2, 3], @filters.sort_numeric([[1], [3], [2]])) # nested arrays get flattened + assert_equal([1.1, 2, BigDecimal(3), 10], @filters.sort_numeric([10, BigDecimal(3), 1.1, 2])) + end + + def test_sort_numeric_with_property + assert_equal([], @filters.sort_numeric([], "a")) + assert_equal([{}], @filters.sort_numeric([{}], "a")) + assert_equal([{ "a" => 2 }, { "a" => 10 }], @filters.sort_numeric([{ "a" => 10 }, { "a" => 2 }], "a")) + assert_equal([{ "a" => "2" }, { "a" => "10" }], @filters.sort_numeric([{ "a" => "10" }, { "a" => "2" }], "a")) + assert_equal([{ "a" => "2" }, { "a" => "10" }, { "b" => "1" }], @filters.sort_numeric([{ "a" => "10" }, { "b" => "1" }, { "a" => "2" }], "a")) + assert_equal([{ "a" => 1 }, { "a" => "2" }, { "a" => "10" }], @filters.sort_numeric([{ "a" => "10" }, { "a" => 1 }, { "a" => "2" }], "a")) + assert_equal([1, 2, 3], @filters.sort_numeric([[1], [3], [2]]), "a") + end + + def test_sort_numeric_input_is_not_an_array + assert_equal([], @filters.sort_numeric(nil)) + assert_equal([true], @filters.sort_numeric(true)) + assert_equal([false], @filters.sort_numeric(false)) + assert_equal([42], @filters.sort_numeric(42)) + assert_equal([42.5], @filters.sort_numeric(42.5)) + assert_equal(["42.5"], @filters.sort_numeric("42.5")) + assert_equal([{ "a" => "42.5" }], @filters.sort_numeric({ "a" => "42.5" })) + assert_equal([], @filters.sort_numeric(nil, "a")) + assert_nil(@filters.sort_numeric(true, "a")) + assert_nil(@filters.sort_numeric(false, "a")) + assert_equal([42], @filters.sort_numeric(42, "a")) + assert_nil(@filters.sort_numeric(42.5, "a")) + assert_equal(["42.5"], @filters.sort_numeric("42.5", "a")) + assert_equal([{ "a" => "42.5" }], @filters.sort_numeric({ "a" => "42.5" }, "a")) + end + def test_uniq assert_equal(["foo"], @filters.uniq("foo")) assert_equal([1, 3, 2, 4], @filters.uniq([1, 1, 3, 2, 3, 1, 4, 3, 2, 1]))