From 2b45f5eed9d1162b9b75c3c9efcbf4b2eed5a276 Mon Sep 17 00:00:00 2001 From: tatematsu-k Date: Tue, 4 Nov 2025 10:47:54 +0900 Subject: [PATCH] Add length predicates for string length search Implements predicates to search by string length: - length_eq: equals - length_lt: less than - length_lteq: less than or equal - length_gt: greater than - length_gteq: greater than or equal These predicates wrap the attribute in LENGTH() or CHAR_LENGTH() function based on the database adapter. Closes #1492 --- lib/ransack/constants.rb | 25 ++++++++++ lib/ransack/nodes/condition.rb | 33 +++++++++++-- spec/ransack/predicate_spec.rb | 85 ++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/lib/ransack/constants.rb b/lib/ransack/constants.rb index 027e87be3..77dbca91e 100644 --- a/lib/ransack/constants.rb +++ b/lib/ransack/constants.rb @@ -155,6 +155,31 @@ module Constants type: :boolean, validator: proc { |v| BOOLEAN_VALUES.include?(v) }, formatter: proc { |v| nil } } + ], + ['length_eq'.freeze, { + arel_predicate: 'eq'.freeze, + type: :integer + } + ], + ['length_lt'.freeze, { + arel_predicate: 'lt'.freeze, + type: :integer + } + ], + ['length_lteq'.freeze, { + arel_predicate: 'lteq'.freeze, + type: :integer + } + ], + ['length_gt'.freeze, { + arel_predicate: 'gt'.freeze, + type: :integer + } + ], + ['length_gteq'.freeze, { + arel_predicate: 'gteq'.freeze, + type: :integer + } ] ].freeze diff --git a/lib/ransack/nodes/condition.rb b/lib/ransack/nodes/condition.rb index 0b024fdc9..9c11044ee 100644 --- a/lib/ransack/nodes/condition.rb +++ b/lib/ransack/nodes/condition.rb @@ -343,14 +343,20 @@ def combinator_method def format_predicate(attribute) arel_pred = arel_predicate_for_attribute(attribute) arel_values = formatted_values_for_attribute(attribute) - + # For LIKE predicates, wrap the value in Arel::Nodes.build_quoted to prevent # ActiveRecord normalization from affecting wildcard patterns if like_predicate?(arel_pred) arel_values = Arel::Nodes.build_quoted(arel_values) end - - predicate = attr_value_for_attribute(attribute).public_send(arel_pred, arel_values) + + attr_value = if length_predicate? + length_function_for_attribute(attribute) + else + attr_value_for_attribute(attribute) + end + + predicate = attr_value.public_send(arel_pred, arel_values) if in_predicate?(predicate) predicate.right = predicate.right.map do |pr| @@ -391,6 +397,27 @@ def replace_right_node?(predicate) attribute_type == :integer && arel_node.value.is_a?(Integer) end + def length_predicate? + predicate_name.to_s.start_with?('length_') + end + + def length_function_for_attribute(attribute) + adapter_name = ActiveRecord::Base.connection.adapter_name + function_name = case adapter_name + when "PostGIS".freeze, "PostgreSQL".freeze + "CHAR_LENGTH".freeze + when "Mysql2".freeze + "CHAR_LENGTH".freeze + else + "LENGTH".freeze + end + + Arel::Nodes::NamedFunction.new( + function_name, + [attr_value_for_attribute(attribute)] + ) + end + def valid_combinator? attributes.size < 2 || Constants::AND_OR.include?(combinator) end diff --git a/spec/ransack/predicate_spec.rb b/spec/ransack/predicate_spec.rb index cf84e200d..34e86de0b 100644 --- a/spec/ransack/predicate_spec.rb +++ b/spec/ransack/predicate_spec.rb @@ -461,6 +461,91 @@ module Ransack end end + describe 'length_eq' do + it 'generates a LENGTH(column) = value condition' do + @s.name_length_eq = 4 + field = "#{quote_table_name("people")}.#{quote_column_name("name")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) = 4/ + end + + it 'does not generate a condition for nil' do + @s.name_length_eq = nil + expect(@s.result.to_sql).not_to match /WHERE/ + end + + it "works with attribute names containing 'length'" do + @s.length_field_length_eq = 8 + field = "#{quote_table_name("people")}.#{quote_column_name("length_field")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) = 8/ + end + end + + describe 'length_lt' do + it 'generates a LENGTH(column) < value condition' do + @s.name_length_lt = 5 + field = "#{quote_table_name("people")}.#{quote_column_name("name")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) < 5/ + end + + it 'does not generate a condition for nil' do + @s.name_length_lt = nil + expect(@s.result.to_sql).not_to match /WHERE/ + end + end + + describe 'length_lteq' do + it 'generates a LENGTH(column) <= value condition' do + @s.name_length_lteq = 4 + field = "#{quote_table_name("people")}.#{quote_column_name("name")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) <= 4/ + end + + it 'does not generate a condition for nil' do + @s.name_length_lteq = nil + expect(@s.result.to_sql).not_to match /WHERE/ + end + + it "works with attribute names containing 'length'" do + @s.length_field_length_lteq = 10 + field = "#{quote_table_name("people")}.#{quote_column_name("length_field")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) <= 10/ + end + + it "works with attribute names starting with 'length'" do + @s.length_of_name_length_lteq = 5 + field = "#{quote_table_name("people")}.#{quote_column_name("length_of_name")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) <= 5/ + end + end + + describe 'length_gt' do + it 'generates a LENGTH(column) > value condition' do + # Use email instead of name to avoid conflict with name_length column + @s.email_length_gt = 10 + field = "#{quote_table_name("people")}.#{quote_column_name("email")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) > 10/ + end + + it 'does not generate a condition for nil' do + @s.email_length_gt = nil + expect(@s.result.to_sql).not_to match /WHERE/ + end + end + + describe 'length_gteq' do + it 'generates a LENGTH(column) >= value condition' do + # Use email instead of name to avoid conflict with name_length column + @s.email_length_gteq = 10 + field = "#{quote_table_name("people")}.#{quote_column_name("email")}" + expect(@s.result.to_sql).to match /(CHAR_LENGTH|LENGTH)\(#{field}\) >= 10/ + end + + it 'does not generate a condition for nil' do + @s.email_length_gteq = nil + expect(@s.result.to_sql).not_to match /WHERE/ + end + end + context "defining custom predicates" do describe "with 'not_in' arel predicate" do before do