Compare commits

...
9 changed files with 253 additions and 165 deletions
+10
View File
@@ -2,9 +2,19 @@
## 5.8.0 (unreleased)
## 5.7.2 2025-01-31
* Fix array filters to not support nested properties
## 5.7.1 2025-01-24
* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays
* Fix the `has` filter to return `false` when filtering empty arrays
## 5.7.0 2025-01-16
### Features
* Add `find`, `find_index`, `has`, and `reject` filters to arrays
* Compatibility with Ruby 3.4
+9
View File
@@ -27,6 +27,15 @@ class Servlet < LiquidServlet
{ 'products' => products_list, 'more_products' => more_products_list, 'description' => description, 'section' => 'Snowboards', 'cool_products' => true }
end
def settings
{
'settings' => {
'text' => 'Liquid',
'font' => 'Arial'
}
}
end
private
def products_list
View File
+121
View File
@@ -0,0 +1,121 @@
<style>
* {
zoom: 1.1;
font-family: Menlo;
color: rgb(13, 13, 13);
}
.preview {
display: flex;
gap: 2rem;
margin: 1rem 0;
}
.preview > div {
flex: 1;
}
code, pre {
background: #f5f5f5;
padding: 0.5rem;
border-radius: 4px;
display: block;
}
.label {
font-weight: bold;
margin-bottom: 0.5rem;
}
</style>
<h1>let</h1>
<hr>
<div class="preview">
<div>
<div class="label">Liquid source</div>
<pre>
{%- raw -%}
{% assign text = "My text" %}
{{ settings }}
{{ text }}
{%- endraw -%}
</pre>
</div>
<div>
<div class="label">HTML output</div>
<pre>
{% assign text = "My text" %}
{{ settings }}
{{ text -}}
</pre>
</div>
</div>
<div class="preview">
<div>
<div class="label">Liquid source</div>
<pre>
{%- raw -%}
{% let (text, font): settings %}
text: {{ text }},
font: {{ font }}
{% endlet %}
{%- endraw -%}
</pre>
</div>
<div>
<div class="label">HTML output</div>
<pre>
{%- let (text, font): settings %}
text: {{ text }},
font: {{ font }}
{% endlet -%}
</pre>
</div>
</div>
<div class="preview">
<div>
<div class="label">Liquid source</div>
<pre>
{%- raw -%}
{% let font: settings.font %}
text: {{ text }},
font: {{ font }}
{% endlet %}
{%- endraw -%}
</pre>
</div>
<div>
<div class="label">HTML output</div>
<pre>
{%- let font: settings.font %}
text: {{ text }},
font: {{ font }}
{% endlet -%}
</pre>
</div>
</div>
<div class="preview">
<div>
<div class="label">Liquid source</div>
<pre>
{%- raw -%}
Global is not impacted:
- text: {{ text }},
- font: {{ font }}
{%- endraw -%}
</pre>
</div>
<div>
<div class="label">HTML output</div>
<pre>
Global is not impacted:
- text: {{ text }},
- font: {{ font }}
</pre>
</div>
</div>
+13 -38
View File
@@ -387,7 +387,7 @@ module Liquid
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 { |a, b| nil_safe_compare(a[property], b[property]) }
rescue TypeError
raise_property_error(property)
end
@@ -416,7 +416,7 @@ module Liquid
end
elsif ary.all? { |el| el.respond_to?(:[]) }
begin
ary.sort { |a, b| nil_safe_casecmp(fetch_property(a, property), fetch_property(b, property)) }
ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) }
rescue TypeError
raise_property_error(property)
end
@@ -459,7 +459,7 @@ module Liquid
# @liquid_syntax array | some: string, string
# @liquid_return [boolean]
def has(input, property, target_value = nil)
filter_array(input, property, target_value) { |ary, &block| ary.any?(&block) }
filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) }
end
# @liquid_public_docs
@@ -472,7 +472,7 @@ module Liquid
# @liquid_syntax array | find: string, string
# @liquid_return [untyped]
def find(input, property, target_value = nil)
filter_array(input, property, target_value) { |ary, &block| ary.find(&block) }
filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) }
end
# @liquid_public_docs
@@ -485,7 +485,7 @@ module Liquid
# @liquid_syntax array | find_index: string, string
# @liquid_return [number]
def find_index(input, property, target_value = nil)
filter_array(input, property, target_value) { |ary, &block| ary.find_index(&block) }
filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) }
end
# @liquid_public_docs
@@ -504,7 +504,7 @@ module Liquid
[]
else
ary.uniq do |item|
fetch_property(item, property)
item[property]
rescue TypeError
raise_property_error(property)
rescue NoMethodError
@@ -540,7 +540,7 @@ module Liquid
if property == "to_liquid"
e
elsif e.respond_to?(:[])
r = fetch_property(e, property)
r = e[property]
r.is_a?(Proc) ? r.call : r
end
end
@@ -564,7 +564,7 @@ module Liquid
[]
else
ary.reject do |item|
fetch_property(item, property).nil?
item[property].nil?
rescue TypeError
raise_property_error(property)
rescue NoMethodError
@@ -950,7 +950,7 @@ module Liquid
if property.nil?
item
elsif item.respond_to?(:[])
fetch_property(item, property)
item[property]
else
0
end
@@ -969,16 +969,16 @@ module Liquid
attr_reader :context
def filter_array(input, property, target_value, &block)
def filter_array(input, property, target_value, default_value = [], &block)
ary = InputIterator.new(input, context)
return [] if ary.empty?
return default_value if ary.empty?
block.call(ary) do |item|
if target_value.nil?
fetch_property(item, property)
item[property]
else
fetch_property(item, property) == target_value
item[property] == target_value
end
rescue TypeError
raise_property_error(property)
@@ -988,31 +988,6 @@ module Liquid
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
+21 -19
View File
@@ -1,24 +1,25 @@
# frozen_string_literal: true
require_relative "tags/table_row"
require_relative "tags/echo"
require_relative "tags/if"
require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
require_relative "tags/continue"
require_relative "tags/capture"
require_relative "tags/decrement"
require_relative "tags/unless"
require_relative "tags/increment"
require_relative "tags/comment"
require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
require_relative 'tags/table_row'
require_relative 'tags/echo'
require_relative 'tags/if'
require_relative 'tags/break'
require_relative 'tags/inline_comment'
require_relative 'tags/for'
require_relative 'tags/assign'
require_relative 'tags/ifchanged'
require_relative 'tags/case'
require_relative 'tags/include'
require_relative 'tags/continue'
require_relative 'tags/capture'
require_relative 'tags/decrement'
require_relative 'tags/unless'
require_relative 'tags/increment'
require_relative 'tags/comment'
require_relative 'tags/raw'
require_relative 'tags/render'
require_relative 'tags/cycle'
require_relative 'tags/let'
module Liquid
module Tags
@@ -42,6 +43,7 @@ module Liquid
'if' => If,
'echo' => Echo,
'tablerow' => TableRow,
'let' => Let,
}.freeze
end
end
+40
View File
@@ -0,0 +1,40 @@
# frozen_string_literal: true
module Liquid
class Let < Liquid::Block
MULTI_ASSIGNMENT = /\A\(\s*(?<vars>[^)]+)\s*\):\s*(?<source>[^\s]+)\z/
VAR_ASSIGNMENTS = /(?<var>[\w-]+)\s*:\s*(?<expr>[^\s,]+)/
def initialize(tag_name, markup, options)
super(tag_name, markup, options)
@assignments = parse_markup(markup.strip)
end
def render(context)
subcontext = context.new_isolated_subcontext
@assignments.each do |var, expression|
subcontext[var] = lookup(context, expression)
end
super(subcontext)
end
private
def parse_markup(markup)
if (m = MULTI_ASSIGNMENT.match(markup))
variables = m[:vars].split(/\s*,\s*/)
source = m[:source]
variables.map { |v| [v, "#{source}.#{v}"] }.to_h
else
markup.scan(VAR_ASSIGNMENTS).to_h
end
end
def lookup(context, expression)
variable_lookup = Liquid::VariableLookup.new(expression)
variable_lookup.evaluate(context)
end
end
end
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.7.0"
VERSION = "5.7.2"
end
+38 -107
View File
@@ -54,30 +54,6 @@ class TestEnumerable < Liquid::Drop
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
@@ -157,6 +133,18 @@ class StandardFiltersTest < Minitest::Test
assert_equal([], @filters.slice(input, -(1 << 63), 6))
end
def test_find_on_empty_array
assert_nil(@filters.find([], 'foo', 'bar'))
end
def test_find_index_on_empty_array
assert_nil(@filters.find_index([], 'foo', 'bar'))
end
def test_has_on_empty_array
refute(@filters.has([], 'foo', 'bar'))
end
def test_truncate
assert_equal('1234...', @filters.truncate('1234567890', 7))
assert_equal('1234567890', @filters.truncate('1234567890', 20))
@@ -426,15 +414,6 @@ class StandardFiltersTest < Minitest::Test
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
@@ -471,15 +450,6 @@ class StandardFiltersTest < Minitest::Test
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
@@ -496,15 +466,6 @@ class StandardFiltersTest < Minitest::Test
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
@@ -614,15 +575,6 @@ class StandardFiltersTest < Minitest::Test
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] })
@@ -939,15 +891,6 @@ class StandardFiltersTest < Minitest::Test
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 },
@@ -976,6 +919,18 @@ class StandardFiltersTest < Minitest::Test
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
end
def test_has_with_empty_arrays
template = <<~LIQUID
{%- assign has_product = products | has: 'title.content', 'Not found' -%}
{%- unless has_product -%}
Product not found.
{%- endunless -%}
LIQUID
expected_output = "Product not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_has_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
@@ -1004,16 +959,6 @@ class StandardFiltersTest < Minitest::Test
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 },
@@ -1032,14 +977,16 @@ class StandardFiltersTest < Minitest::Test
assert_template_result(expected_output, template, { "products" => products })
end
def test_find_with_deep_enumerables
def test_find_with_empty_arrays
template = <<~LIQUID
{%- assign product = products | find: 'title.content', 'Pro goggles' -%}
{{- product.title.content -}}
{%- assign product = products | find: 'title.content', 'Not found' -%}
{%- unless product -%}
Product not found.
{%- endunless -%}
LIQUID
expected_output = "Pro goggles"
expected_output = "Product not found."
assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new })
assert_template_result(expected_output, template, { "products" => [] })
end
def test_find_index_with_value
@@ -1060,14 +1007,16 @@ class StandardFiltersTest < Minitest::Test
assert_template_result(expected_output, template, { "products" => products })
end
def test_find_index_with_deep_enumerables
def test_find_index_with_empty_arrays
template = <<~LIQUID
{%- assign index = products | find_index: 'title.content', 'Alpine jacket' -%}
{{- index -}}
{%- assign index = products | find_index: 'title.content', 'Not found' -%}
{%- unless index -%}
Index not found.
{%- endunless -%}
LIQUID
expected_output = "2"
expected_output = "Index not found."
assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new })
assert_template_result(expected_output, template, { "products" => [] })
end
def test_where
@@ -1168,15 +1117,6 @@ class StandardFiltersTest < Minitest::Test
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
@@ -1328,15 +1268,6 @@ class StandardFiltersTest < Minitest::Test
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)