Use BinaryExpression instead of Condition for comparisons

This commit is contained in:
Charles-P. Clermont
2026-01-26 16:52:31 -05:00
parent 0b890904f0
commit f60d3f0a41
10 changed files with 200 additions and 82 deletions
+1
View File
@@ -62,6 +62,7 @@ require 'liquid/interrupts'
require 'liquid/tags' require 'liquid/tags'
require "liquid/environment" require "liquid/environment"
require 'liquid/lexer' require 'liquid/lexer'
require 'liquid/method_literal'
require 'liquid/binary_expression' require 'liquid/binary_expression'
require 'liquid/parser' require 'liquid/parser'
require 'liquid/i18n' require 'liquid/i18n'
+55 -17
View File
@@ -2,39 +2,41 @@
module Liquid module Liquid
class BinaryExpression class BinaryExpression
attr_reader :left, :operator, :right attr_reader :operator
attr_accessor :left_node, :right_node
def initialize(left, operator, right) def initialize(left, operator, right)
@left = left @left_node = left
@operator = operator @operator = operator
@right = right @right_node = right
end end
def evaluate(context) def evaluate(context)
left_value = value(left, context) left = value(left_node, context)
right_value = value(@right, context) right = value(right_node, context)
case operator case operator
when '>' when '>'
left_value > right_value left > right if can_compare?(left, right)
when '>=' when '>='
left_value >= right_value left >= right if can_compare?(left, right)
when '<' when '<'
left_value < right_value left < right if can_compare?(left, right)
when '<=' when '<='
left_value <= right_value left <= right if can_compare?(left, right)
when '==' when '=='
left_value == right_value equal_variables(left, right)
when '!=', '<>' when '!=', '<>'
left_value != right_value !equal_variables(left, right)
when 'contains' when 'contains'
if left_value && right_value && left_value.respond_to?(:include?) contains(left, right)
right_value = right_value.to_s if left_value.is_a?(String)
left_value.include?(right_value)
else
false
end
end end
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message
end
def to_s
"(#{left_node} #{operator} #{right_node})"
end end
private private
@@ -42,5 +44,41 @@ module Liquid
def value(expr, context) def value(expr, context)
Utils.to_liquid_value(context.evaluate(expr)) Utils.to_liquid_value(context.evaluate(expr))
end end
def can_compare?(left, right)
left.respond_to?(operator) && right.respond_to?(operator) && !left.is_a?(Hash) && !right.is_a?(Hash)
end
def contains(left, right)
if left && right && left.respond_to?(:include?)
right = right.to_s if left.is_a?(String)
left.include?(right)
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end
def apply_method_literal(node, other)
other.send(node.method_name) if other.respond_to?(node.method_name)
end
def equal_variables(left, right)
return apply_method_literal(left, right) if left.is_a?(MethodLiteral)
return apply_method_literal(right, left) if right.is_a?(MethodLiteral)
left == right
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
@node.left_node,
@node.right_node,
]
end
end
end end
end end
-10
View File
@@ -29,16 +29,6 @@ module Liquid
left.b.include?(right.b) left.b.include?(right.b)
end, end,
} }
class MethodLiteral
attr_reader :method_name, :to_s
def initialize(method_name, to_s)
@method_name = method_name
@to_s = to_s
end
end
@@method_literals = { @@method_literals = {
'blank' => MethodLiteral.new(:blank?, '').freeze, 'blank' => MethodLiteral.new(:blank?, '').freeze,
'empty' => MethodLiteral.new(:empty?, '').freeze, 'empty' => MethodLiteral.new(:empty?, '').freeze,
+2 -2
View File
@@ -9,8 +9,8 @@ module Liquid
'' => nil, '' => nil,
'true' => true, 'true' => true,
'false' => false, 'false' => false,
'blank' => '', 'blank' => MethodLiteral.new(:blank?, '').freeze,
'empty' => '', 'empty' => MethodLiteral.new(:empty?, '').freeze,
}.freeze }.freeze
DOT = ".".ord DOT = ".".ord
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module Liquid
class MethodLiteral
attr_reader :method_name, :to_s
def initialize(method_name, to_s)
@method_name = method_name
@to_s = to_s
end
def to_liquid
to_s
end
end
end
+1 -7
View File
@@ -96,13 +96,7 @@ module Liquid
end end
def parse_comparison(p) def parse_comparison(p)
a = parse_expression(p) Condition.new(p.expression)
if (op = p.consume?(:comparison) || p.consume?(:equality))
b = parse_expression(p)
Condition.new(a, op, b)
else
Condition.new(a)
end
end end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
-22
View File
@@ -147,28 +147,6 @@ class IfElseTagTest < Minitest::Test
assert_raises(SyntaxError) { assert_template_result('', '{% if %}') } assert_raises(SyntaxError) { assert_template_result('', '{% if %}') }
end end
def test_if_with_custom_condition
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
assert_template_result('yes', %({% if 'bob' contains 'o' %}yes{% endif %}))
assert_template_result('no', %({% if 'bob' contains 'f' %}yes{% else %}no{% endif %}))
ensure
Condition.operators['contains'] = original_op
end
def test_operators_are_ignored_unless_isolated
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
assert_template_result(
'yes',
%({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}),
)
ensure
Condition.operators['contains'] = original_op
end
def test_operators_are_whitelisted def test_operators_are_whitelisted
assert_raises(SyntaxError) do assert_raises(SyntaxError) do
assert_template_result('', %({% if 1 or throw or or 1 %}yes{% endif %})) assert_template_result('', %({% if 1 or throw or or 1 %}yes{% endif %}))
+101
View File
@@ -0,0 +1,101 @@
# frozen_string_literal: true
require 'test_helper'
class BinaryExpressionTest < Minitest::Test
include Liquid
def test_simple_comparison_evaluation
assert_eval(false, BinaryExpression.new(5, ">", 5))
assert_eval(true, BinaryExpression.new(5, ">=", 5))
assert_eval(false, BinaryExpression.new(5, "<", 5))
assert_eval(true, BinaryExpression.new(5, "<=", 5))
assert_eval(true, BinaryExpression.new("abcd", "contains", "a"))
end
def test_complex_evaluation
# 1 > 2 == 2 > 3
assert_eval(true, BinaryExpression.new(
BinaryExpression.new(1, '>', 2),
'==',
BinaryExpression.new(2, '>', 3),
))
# 1 > 2 != 2 > 3
assert_eval(false, BinaryExpression.new(
BinaryExpression.new(1, '>', 2),
'!=',
BinaryExpression.new(2, '>', 3),
))
# a > 0 == b.prop > 0
assert_eval(
true,
BinaryExpression.new(
BinaryExpression.new(var('a'), '>', 0),
'==',
BinaryExpression.new(var('b.prop'), '>', 0),
),
{ 'a' => 1, 'b' => { 'prop' => 2 } },
)
end
def test_method_literal_equality
empty = MethodLiteral.new(:empty?, '')
# a == empty, empty == a
assert_eval(false, BinaryExpression.new("123", "==", empty))
assert_eval(true, BinaryExpression.new("", "==", empty))
assert_eval(false, BinaryExpression.new(empty, "==", "123"))
assert_eval(true, BinaryExpression.new(empty, "==", ""))
# a does not have .empty?
assert_eval(nil, BinaryExpression.new(1, "==", empty))
assert_eval(nil, BinaryExpression.new(true, "==", empty))
assert_eval(nil, BinaryExpression.new(false, "==", empty))
assert_eval(nil, BinaryExpression.new(nil, "==", empty))
# a != empty
assert_eval(true, BinaryExpression.new("123", "!=", empty))
assert_eval(false, BinaryExpression.new("", "!=", empty))
assert_eval(true, BinaryExpression.new(empty, "!=", "123"))
assert_eval(false, BinaryExpression.new(empty, "!=", ""))
# a does not have .empty?
assert_eval(true, BinaryExpression.new(1, "!=", empty))
assert_eval(true, BinaryExpression.new(true, "!=", empty))
assert_eval(true, BinaryExpression.new(false, "!=", empty))
assert_eval(true, BinaryExpression.new(nil, "!=", empty))
end
def test_method_literal_comparison
empty = MethodLiteral.new(:empty?, '')
['>', '>='].each do |op|
assert_eval(nil, BinaryExpression.new("123", op, empty))
assert_eval(nil, BinaryExpression.new("", op, empty))
assert_eval(nil, BinaryExpression.new(empty, op, "123"))
assert_eval(nil, BinaryExpression.new(empty, op, ""))
end
# Interesting case, contains on strings does include?(right.to_s)
assert_eval(true, BinaryExpression.new("123", "contains", empty))
assert_eval(true, BinaryExpression.new("", "contains", empty))
end
def assert_eval(expected, expr, assigns = {})
actual = expr.evaluate(context(assigns))
message = "Expected '#{expr}' to evaluate to '#{expected}'"
return assert_nil(actual, message) if expected.nil?
assert_equal(expected, actual, message)
end
def var(markup)
Parser.new(markup).variable_lookup
end
def context(assigns = {})
Context.build(outer_scope: assigns)
end
end
+1 -1
View File
@@ -183,7 +183,7 @@ class ConditionUnitTest < Minitest::Test
parser = parse_context.new_parser('blank') parser = parse_context.new_parser('blank')
result = Condition.parse_expression(parser) result = Condition.parse_expression(parser)
assert_instance_of(Condition::MethodLiteral, result) assert_instance_of(MethodLiteral, result)
end end
# Tests for blank? comparison without ActiveSupport # Tests for blank? comparison without ActiveSupport
+23 -23
View File
@@ -82,24 +82,24 @@ class ParserUnitTest < Minitest::Test
expr = p.expression expr = p.expression
assert(expr.is_a?(BinaryExpression)) assert(expr.is_a?(BinaryExpression))
assert_equal('==', expr.operator) assert_equal('==', expr.operator)
assert_equal('a', expr.left.name) assert_equal('a', expr.left_node.name)
assert_equal('b', expr.right.name) assert_equal('b', expr.right_node.name)
# BinaryExpression(==) # BinaryExpression(==)
# left: BinaryExpression(<) # left_node: BinaryExpression(<)
# left: 0 # left_node: 0
# right: 5 # right_node: 5
# right: BinaryExpression(>) # right_node: BinaryExpression(>)
# left: 6 # left_node: 6
# right: 1 # right_node: 1
p = new_parser("0 < 5 == 6 > 1") p = new_parser("0 < 5 == 6 > 1")
expr = p.expression expr = p.expression
assert(expr.is_a?(BinaryExpression)) assert(expr.is_a?(BinaryExpression))
assert_equal('==', expr.operator) assert_equal('==', expr.operator)
assert_equal(0, expr.left.left) assert_equal(0, expr.left_node.left_node)
assert_equal(5, expr.left.right) assert_equal(5, expr.left_node.right_node)
assert_equal(6, expr.right.left) assert_equal(6, expr.right_node.left_node)
assert_equal(1, expr.right.right) assert_equal(1, expr.right_node.right_node)
end end
def test_comparison def test_comparison
@@ -107,23 +107,23 @@ class ParserUnitTest < Minitest::Test
expr = p.expression expr = p.expression
assert(expr.is_a?(BinaryExpression)) assert(expr.is_a?(BinaryExpression))
assert_equal('>', expr.operator) assert_equal('>', expr.operator)
assert(expr.left.is_a?(VariableLookup)) assert(expr.left_node.is_a?(VariableLookup))
assert_equal('a', expr.left.name) assert_equal('a', expr.left_node.name)
assert(expr.right.is_a?(VariableLookup)) assert(expr.right_node.is_a?(VariableLookup))
assert_equal('b', expr.right.name) assert_equal('b', expr.right_node.name)
# BinaryExpression(>=) # BinaryExpression(>=)
# left: BinaryExpression(>) # left_node: BinaryExpression(>)
# left: 10 # left_node: 10
# right: 5 # right_node: 5
# right: 4 # right_node: 4
p = new_parser("10 > 5 >= 4") p = new_parser("10 > 5 >= 4")
expr = p.expression expr = p.expression
assert(expr.is_a?(BinaryExpression)) assert(expr.is_a?(BinaryExpression))
assert_equal('>=', expr.operator) assert_equal('>=', expr.operator)
assert_equal(10, expr.left.left) assert_equal(10, expr.left_node.left_node)
assert_equal(5, expr.left.right) assert_equal(5, expr.left_node.right_node)
assert_equal(4, expr.right) assert_equal(4, expr.right_node)
end end
def test_number def test_number