Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/arel_attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ module ArelAttribute
class Error < StandardError; end
end

require "arel_attribute/arel_ruby"
require "arel_attribute/base"
249 changes: 249 additions & 0 deletions lib/arel_attribute/arel_ruby.rb
Original file line number Diff line number Diff line change
@@ -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
59 changes: 58 additions & 1 deletion lib/arel_attribute/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading