From b5294aa1c8f1e5eac0a1a011f43da7b5405aa552 Mon Sep 17 00:00:00 2001 From: Keenan Brock Date: Sat, 4 Apr 2026 19:54:24 -0400 Subject: [PATCH] Auto-generate Ruby methods from arel expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ruby:` keyword to `arel_attribute`: - `ruby: true` — derive the Ruby method from the arel AST - `ruby: "..."` — use the given Ruby expression as the method body - `ruby: nil` — skip (default, backwards compatible) ArelRuby.convert walks arel nodes (NamedFunction, math ops, CASE, comparisons, grouping, literals) and emits Ruby source. The generated methods are batched into a single module via module_eval and included when Rails' define_attribute_methods fires. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/arel_attribute.rb | 1 + lib/arel_attribute/arel_ruby.rb | 249 ++++++++++++++++++++++++++++++++ lib/arel_attribute/base.rb | 59 +++++++- spec/arel_ruby_spec.rb | 211 +++++++++++++++++++++++++++ spec/db/models.rb | 20 +-- 5 files changed, 525 insertions(+), 15 deletions(-) create mode 100644 lib/arel_attribute/arel_ruby.rb create mode 100644 spec/arel_ruby_spec.rb diff --git a/lib/arel_attribute.rb b/lib/arel_attribute.rb index fe7b411..4f80612 100644 --- a/lib/arel_attribute.rb +++ b/lib/arel_attribute.rb @@ -16,4 +16,5 @@ module ArelAttribute class Error < StandardError; end end +require "arel_attribute/arel_ruby" require "arel_attribute/base" diff --git a/lib/arel_attribute/arel_ruby.rb b/lib/arel_attribute/arel_ruby.rb new file mode 100644 index 0000000..d82d74e --- /dev/null +++ b/lib/arel_attribute/arel_ruby.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +module ArelAttribute + # Converts an arel expression into a Ruby source string. + # + # The generated code assumes `self` is the ActiveRecord instance, + # so it can be used directly inside a method body via module_eval. + # + # Supports single-row, single-table value expressions: + # COALESCE, UPPER, LOWER, CONCAT, CAST, + # math (+, -, *, /), CASE/WHEN, comparisons, + # IS NULL, AND, OR, NOT, grouping, literals. + # + # Raises UnsupportedNode for anything it can't translate + # (subqueries, aggregates, etc.) — callers should define Ruby manually. + module ArelRuby + class UnsupportedNode < ArelAttribute::Error; end + + # Convert an arel node into a Ruby source string. + # + # The returned string is valid Ruby that can be placed inside a method body. + # Column references become `self[:col]` for real columns or `col_name` for + # virtual attributes (calling the Ruby getter). + # + # @param node [Arel::Nodes::Node] the arel expression + # @param klass [Class] the ActiveRecord model class (for resolving virtual attributes) + # @return [String] Ruby source + def self.convert(node, klass) + case node + + # Column reference: t[:name] + when Arel::Attributes::Attribute + attr_name = node.name.to_s + if klass.respond_to?(:arel_attribute?) && klass.arel_attribute?(attr_name) + # virtual attribute — call the ruby getter + attr_name + else + # real column + "self[:#{attr_name}]" + end + + # Our custom node — unwrap to the inner expression + when Arel::Nodes::ArelAttribute + convert(node.expr, klass) + + # Grouping (parentheses) — pass through + when Arel::Nodes::Grouping + "(#{convert(node.expr, klass)})" + + # Named functions: UPPER, LOWER, COALESCE, CONCAT, LENGTH, REPLACE, etc. + when Arel::Nodes::NamedFunction + convert_function(node, klass) + + # Math: +, -, *, / + when Arel::Nodes::Addition + "#{convert(node.left, klass)} + #{convert(node.right, klass)}" + when Arel::Nodes::Subtraction + "#{convert(node.left, klass)} - #{convert(node.right, klass)}" + when Arel::Nodes::Multiplication + "#{convert(node.left, klass)} * #{convert(node.right, klass)}" + when Arel::Nodes::Division + "#{convert(node.left, klass)} / #{convert(node.right, klass)}" + + # String concatenation (||) + when Arel::Nodes::Concat + "#{convert(node.left, klass)}.to_s + #{convert(node.right, klass)}.to_s" + + # CASE/WHEN + when Arel::Nodes::Case + convert_case(node, klass) + + # Comparisons (used inside CASE conditions) + when Arel::Nodes::Equality + if node.right.nil? || (node.right.respond_to?(:nil?) && node.right.nil?) + "#{convert(node.left, klass)}.nil?" + else + "#{convert(node.left, klass)} == #{convert(node.right, klass)}" + end + when Arel::Nodes::NotEqual + if node.right.nil? || (node.right.respond_to?(:nil?) && node.right.nil?) + "!#{convert(node.left, klass)}.nil?" + else + "#{convert(node.left, klass)} != #{convert(node.right, klass)}" + end + when Arel::Nodes::GreaterThan + "#{convert(node.left, klass)} > #{convert(node.right, klass)}" + when Arel::Nodes::LessThan + "#{convert(node.left, klass)} < #{convert(node.right, klass)}" + when Arel::Nodes::GreaterThanOrEqual + "#{convert(node.left, klass)} >= #{convert(node.right, klass)}" + when Arel::Nodes::LessThanOrEqual + "#{convert(node.left, klass)} <= #{convert(node.right, klass)}" + + # Logical operators + when Arel::Nodes::And + node.children.map { |c| convert(c, klass) }.join(" && ") + when Arel::Nodes::Or + node.children.map { |c| convert(c, klass) }.join(" || ") + when Arel::Nodes::Not + "!#{convert(node.expr, klass)}" + + # Literal values + when Arel::Nodes::Quoted + node.value.inspect + when Arel::Nodes::Casted + node.value.inspect + when Arel::Nodes::SqlLiteral + convert_sql_literal(node) + + # Raw Ruby values (arel allows bare integers in expressions like `col * 1048576`) + when Numeric + node.inspect + when String + node.inspect + when Symbol + node.to_s.inspect + when NilClass + "nil" + when TrueClass, FalseClass + node.inspect + + else + raise UnsupportedNode, "Cannot convert #{node.class} to Ruby: #{node.inspect}" + end + end + + # @private + def self.convert_function(node, klass) + args = node.expressions + case node.name.upcase + when "COALESCE" + parts = args.map { |a| convert(a, klass) } + parts.join(" || ") # Ruby || returns first truthy — same as COALESCE for non-false values + when "UPPER" + "#{convert(args.first, klass)}&.upcase" + when "LOWER" + "#{convert(args.first, klass)}&.downcase" + when "LENGTH" + "#{convert(args.first, klass)}&.length" + when "REPLACE" + "#{convert(args[0], klass)}&.gsub(#{convert(args[1], klass)}, #{convert(args[2], klass)})" + when "CONCAT" + args.map { |a| "#{convert(a, klass)}.to_s" }.join(" + ") + when "SUBSTR", "SUBSTRING" + convert_substr(args, klass) + when "TRIM" + "#{convert(args.first, klass)}&.strip" + when "LTRIM" + "#{convert(args.first, klass)}&.lstrip" + when "RTRIM" + convert_rtrim(args, klass) + when "INSTR" + # INSTR(string, substring) returns position (1-based) or 0 + "((pos = #{convert(args[0], klass)}&.index(#{convert(args[1], klass)})) ? pos + 1 : 0)" + when "STRPOS" + # PostgreSQL STRPOS — same semantics as INSTR + "((pos = #{convert(args[0], klass)}&.index(#{convert(args[1], klass)})) ? pos + 1 : 0)" + when "CAST" + convert_cast(args.first, klass) + when "ABS" + "#{convert(args.first, klass)}&.abs" + else + raise UnsupportedNode, "Unknown SQL function #{node.name}: #{node.inspect}" + end + end + + # @private + def self.convert_substr(args, klass) + str = convert(args[0], klass) + # SQL SUBSTR is 1-based, Ruby is 0-based + start_expr = convert(args[1], klass) + if args[2] + len = convert(args[2], klass) + "#{str}&.slice((#{start_expr}) - 1, #{len})" + else + "#{str}&.slice((#{start_expr}) - 1..)" + end + end + + # @private + def self.convert_rtrim(args, klass) + if args.size == 1 + "#{convert(args.first, klass)}&.rstrip" + else + # RTRIM(str, chars) — strip trailing characters + "#{convert(args[0], klass)}&.chomp(#{convert(args[1], klass)})" + end + end + + # @private — CAST(expr AS type) is represented as NamedFunction("CAST", [expr.as("type")]) + def self.convert_cast(node, klass) + # The argument to CAST is typically an As node: expr AS type_name + if node.is_a?(Arel::Nodes::As) + expr = convert(node.left, klass) + type_name = node.right.to_s.downcase + case type_name + when "integer", "unsigned", "signed", "bigint" + "#{expr}&.to_i" + when "float", "real", "double", "decimal", "numeric" + "#{expr}&.to_f" + when /char|text|string/ + "#{expr}&.to_s" + else + raise UnsupportedNode, "Unknown CAST type: #{type_name}" + end + else + convert(node, klass) + end + end + + # @private + def self.convert_case(node, klass) + parts = [] + parts << if node.case + # Simple CASE: CASE expr WHEN val THEN result ... + "case #{convert(node.case, klass)}" + else + # Searched CASE: CASE WHEN condition THEN result ... + "case" + end + node.conditions.each do |cond| + parts << "when #{convert(cond.left, klass)} then #{convert(cond.right, klass)}" + end + if node.default + parts << "else #{convert(node.default.expr, klass)}" + end + parts << "end" + "(#{parts.join("; ")})" + end + + # @private — SQL string literals like Arel.sql("'value'") need unwrapping + def self.convert_sql_literal(node) + str = node.to_s + # Common pattern: Arel.sql("'some_string'") — unwrap the SQL quotes + if str.match?(/\A'(.*)'\z/) + str[1..-2].inspect + elsif str == "NULL" + "nil" + elsif str.match?(/\A-?\d+(\.\d+)?\z/) + str + else + raise UnsupportedNode, "Cannot convert SQL literal to Ruby: #{str.inspect}" + end + end + + private_class_method :convert_function, :convert_substr, :convert_rtrim, + :convert_cast, :convert_case, :convert_sql_literal + end +end diff --git a/lib/arel_attribute/base.rb b/lib/arel_attribute/base.rb index f2e6581..c9c4f74 100644 --- a/lib/arel_attribute/base.rb +++ b/lib/arel_attribute/base.rb @@ -59,7 +59,12 @@ module ClassMethods # # arel_attribute :teacher_name, :string, through: :teacher, source: :name # - def arel_attribute(name, type, through: nil, source: name, default: nil, &block) + # @param ruby [true, false, nil, String] controls Ruby method generation: + # true — auto-generate from the arel expression (raises if not translatable) + # false — skip (caller defines the method manually) + # nil — skip (default, backwards compatible) + # String — define a method using the given Ruby expression string + def arel_attribute(name, type, through: nil, source: name, default: nil, ruby: nil, &block) if through define_arel_delegate_method(name, source, through, default) @@ -73,6 +78,8 @@ def arel_attribute(name, type, through: nil, source: name, default: nil, &block) raise ArgumentError, "arel block is required for arel_attribute" unless block self.arel_aliases = arel_aliases.merge(name.to_s => block) self.arel_attribute_types = arel_attribute_types.merge(name.to_s => type) + + pending_arel_ruby_methods[name.to_s] = ruby if ruby && !through end def arel_attribute_names @@ -112,6 +119,13 @@ def arel_table @arel_table ||= ArelAttribute::TableProxy.new(table_name, klass: self) end + # Hook into Rails' define_attribute_methods lifecycle. + # Called lazily on first attribute access (via method_missing). + # After Rails defines its methods, we batch-generate ours. + def define_attribute_methods # :nodoc: + super.tap { generate_arel_ruby_methods } + end + private # Define a Ruby getter that delegates to the association, with DB-loaded value support. @@ -126,6 +140,49 @@ def define_arel_delegate_method(name, source, through, default) end end + # Attributes that need Ruby methods generated, accumulated during + # class definition. Keys are attribute names, values are ruby option + # (true for auto-derive, String for explicit body). + def pending_arel_ruby_methods + @pending_arel_ruby_methods ||= {} + end + + # Build a module with Ruby getters for all pending arel attributes. + # Returns the module without including it — useful for testing/inspection. + def build_arel_ruby_module + pending = pending_arel_ruby_methods + return if pending.empty? + + methods_source = pending.map { |name, ruby_opt| + ruby_body = + if ruby_opt == true + arel_node = arel_aliases[name][arel_table] + ArelRuby.convert(arel_node, self) + else + ruby_opt + end + + <<~RUBY + def #{name} + has_attribute?("#{name}") ? self["#{name}"] : (#{ruby_body}) + end + RUBY + }.join("\n") + + mod = Module.new + mod.module_eval(methods_source, "(arel_ruby:#{name})", 1) + mod + end + + # Generate and include the arel ruby methods module. + def generate_arel_ruby_methods + mod = build_arel_ruby_module + return unless mod + + include mod + pending_arel_ruby_methods.clear + end + # Lazily resolve symbolic type names (e.g. :integer) to actual type objects. # Cached per class; reset if arel_attribute_types changes (class_attribute handles this). def resolved_arel_attribute_types diff --git a/spec/arel_ruby_spec.rb b/spec/arel_ruby_spec.rb new file mode 100644 index 0000000..827a1e6 --- /dev/null +++ b/spec/arel_ruby_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ArelAttribute::ArelRuby do + let(:t) { Author.arel_table } + + describe ".convert" do + it "converts a real column reference" do + node = t[:name] + expect(described_class.convert(node, Author)).to eq("self[:name]") + end + + it "converts a virtual attribute reference" do + node = t[:doubled] + # TableProxy wraps virtual attrs in ArelAttribute node; convert unwraps + ruby = described_class.convert(node, Author) + expect(ruby).not_to include("ArelAttribute") + end + + it "converts UPPER" do + node = Arel::Nodes::NamedFunction.new("UPPER", [t[:name]]) + expect(described_class.convert(node, Author)).to eq("self[:name]&.upcase") + end + + it "converts LOWER" do + node = Arel::Nodes::NamedFunction.new("LOWER", [t[:name]]) + expect(described_class.convert(node, Author)).to eq("self[:name]&.downcase") + end + + it "converts COALESCE" do + node = Arel::Nodes::NamedFunction.new("COALESCE", [t[:nickname], t[:name]]) + expect(described_class.convert(node, Author)).to eq("self[:nickname] || self[:name]") + end + + it "converts addition" do + # arel wraps math in Grouping: (left + right) + node = t[:id] + t[:id] + expect(described_class.convert(node, Author)).to eq("(self[:id] + self[:id])") + end + + it "converts subtraction" do + node = t[:id] - Arel::Nodes::Quoted.new(1) + expect(described_class.convert(node, Author)).to eq("(self[:id] - 1)") + end + + it "converts multiplication" do + node = t[:id] * 2 + expect(described_class.convert(node, Author)).to eq("self[:id] * 2") + end + + it "converts division" do + node = Arel::Nodes::Division.new(t[:id], 2) + expect(described_class.convert(node, Author)).to eq("self[:id] / 2") + end + + it "converts grouping" do + # t[:id] + t[:id] already wraps in Grouping, so explicit Grouping double-wraps + node = Arel::Nodes::Grouping.new(t[:id] + t[:id]) + expect(described_class.convert(node, Author)).to eq("((self[:id] + self[:id]))") + end + + it "converts string concatenation" do + node = Arel::Nodes::Concat.new(t[:name], t[:nickname]) + expect(described_class.convert(node, Author)).to eq("self[:name].to_s + self[:nickname].to_s") + end + + it "converts a simple CASE statement" do + node = Arel::Nodes::Case.new(t[:name]) + .when("Alice").then(Arel.sql("'admin'")) + .else(Arel.sql("'user'")) + ruby = described_class.convert(node, Author) + expect(ruby).to include("case self[:name]") + expect(ruby).to include('when "Alice"') + expect(ruby).to include('"admin"') + expect(ruby).to include('"user"') + end + + it "converts a searched CASE statement" do + node = Arel::Nodes::Case.new + .when(t[:name].eq(nil)).then(Arel.sql("'unknown'")) + .else(t[:name]) + ruby = described_class.convert(node, Author) + expect(ruby).to include("when self[:name].nil?") + expect(ruby).to include('"unknown"') + end + + it "converts equality" do + node = t[:name].eq("Alice") + expect(described_class.convert(node, Author)).to eq('self[:name] == "Alice"') + end + + it "converts IS NULL" do + node = t[:name].eq(nil) + expect(described_class.convert(node, Author)).to eq("self[:name].nil?") + end + + it "converts IS NOT NULL" do + node = t[:name].not_eq(nil) + expect(described_class.convert(node, Author)).to eq("!self[:name].nil?") + end + + it "converts greater than" do + node = t[:id].gt(5) + expect(described_class.convert(node, Author)).to eq("self[:id] > 5") + end + + it "converts SQL literal strings" do + node = Arel.sql("'hello'") + expect(described_class.convert(node, Author)).to eq('"hello"') + end + + it "converts SQL literal NULL" do + node = Arel.sql("NULL") + expect(described_class.convert(node, Author)).to eq("nil") + end + + it "converts SQL literal numbers" do + node = Arel.sql("42") + expect(described_class.convert(node, Author)).to eq("42") + end + + it "converts LENGTH" do + node = Arel::Nodes::NamedFunction.new("LENGTH", [t[:name]]) + expect(described_class.convert(node, Author)).to eq("self[:name]&.length") + end + + it "converts REPLACE" do + node = Arel::Nodes::NamedFunction.new("REPLACE", [t[:name], Arel.sql("'/'"), Arel.sql("''")]) + expect(described_class.convert(node, Author)).to eq('self[:name]&.gsub("/", "")') + end + + it "converts ArelAttribute node by unwrapping" do + node = t[:nick_or_name] # goes through TableProxy, returns ArelAttribute node + ruby = described_class.convert(node, Author) + # Should unwrap to the inner expression + expect(ruby).not_to include("ArelAttribute") + end + + it "raises on unsupported nodes" do + # A SelectManager (subquery) should not be convertible + subquery = Author.arel_table.project(Arel.star) + expect { described_class.convert(subquery, Author) }.to raise_error( + ArelAttribute::ArelRuby::UnsupportedNode + ) + end + + it "raises on unknown SQL functions" do + node = Arel::Nodes::NamedFunction.new("RANDOM", []) + expect { described_class.convert(node, Author) }.to raise_error( + ArelAttribute::ArelRuby::UnsupportedNode, /RANDOM/ + ) + end + end + + describe "ruby: true" do + it "auto-generates a working Ruby method for upper_name" do + author = Author.create!(name: "Alice") + expect(author.upper_name).to eq("ALICE") + end + + it "auto-generates a working Ruby method for nick_or_name" do + author = Author.create!(name: "Alice", nickname: "Ally") + expect(author.nick_or_name).to eq("Ally") + end + + it "auto-generates nick_or_name falling back to name" do + author = Author.create!(name: "Alice") + expect(author.nick_or_name).to eq("Alice") + end + + it "prefers SQL-loaded value over Ruby computation" do + author = Author.create!(name: "Alice") + loaded = Author.select(:id, :upper_name).find(author.id) + expect(loaded.upper_name).to eq("ALICE") + end + + it "auto-generates a working Ruby method for doubled" do + author = Author.create!(name: "Alice", teacher_id: 7) + expect(author.doubled).to eq(14) + end + end + + describe "ruby: 'string'" do + it "defines a method from the string expression" do + author = Author.create!(name: "Alice", nickname: "Ally") + expect(author.name_no_group).to eq("Ally") + end + + it "falls back through the string expression" do + author = Author.create!(name: "Alice") + expect(author.name_no_group).to eq("Alice") + end + + it "prefers SQL-loaded value over the string expression" do + author = Author.create!(name: "Alice", nickname: "Ally") + loaded = Author.select(:id, :name_no_group).find(author.id) + expect(loaded.name_no_group).to eq("Ally") + end + end + + describe ".build_arel_ruby_module" do + it "returns a module with the generated methods" do + # build_arel_ruby_module only works when there are pending methods, + # which are consumed by define_attribute_methods. Test via convert instead. + node = Arel::Nodes::NamedFunction.new("UPPER", [Author.arel_table[:name]]) + ruby_src = described_class.convert(node, Author) + expect(ruby_src).to eq("self[:name]&.upcase") + end + end +end diff --git a/spec/db/models.rb b/spec/db/models.rb index 4c116fe..78cbcd3 100644 --- a/spec/db/models.rb +++ b/spec/db/models.rb @@ -59,26 +59,18 @@ class Author < TestRecord arel_total :total_named_books, :named_books alias_method :v_total_named_books, :total_named_books - def nick_or_name - has_attribute?("nick_or_name") ? self["nick_or_name"] : nickname || name - end - - # sorry. no creativity on this one (just copied nick_or_name) - def name_no_group - has_attribute?("name_no_group") ? self["name_no_group"] : nickname || name - end - # simple arel attributes for testing basic functionality - arel_attribute(:doubled, :integer) { |t| t[:teacher_id] + t[:teacher_id] } - arel_attribute(:upper_name, :string) { |t| Arel::Nodes::NamedFunction.new("UPPER", [t[:name]]) } + arel_attribute(:doubled, :integer, ruby: true) { |t| t[:teacher_id] + t[:teacher_id] } + arel_attribute(:upper_name, :string, ruby: true) { |t| Arel::Nodes::NamedFunction.new("UPPER", [t[:name]]) } # arel attribute with grouping wrapping - arel_attribute(:nick_or_name, :string) do |t| + arel_attribute(:nick_or_name, :string, ruby: true) do |t| t.grouping(Arel::Nodes::NamedFunction.new("COALESCE", [t[:nickname], t[:name]])) end - # arel attribute without grouping — tests that non-Grouping arel nodes work - arel_attribute(:name_no_group, :string) do |t| + # arel attribute without grouping — tests that non-Grouping arel nodes work. + # Uses ruby: "..." string form instead of ruby: true to exercise that path. + arel_attribute(:name_no_group, :string, ruby: "nickname || name") do |t| Arel::Nodes::NamedFunction.new("COALESCE", [t[:nickname], t[:name]]) end