From 1f234a93022e6107efdb79acca08b12a2018e0cc Mon Sep 17 00:00:00 2001 From: Keenan Brock Date: Sat, 4 Apr 2026 20:53:47 -0400 Subject: [PATCH] Move ArelAttribute::Base into its own file Pure move: Base module extracted from lib/arel_attribute.rb into lib/arel_attribute/base.rb. No content changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/arel_attribute.rb | 141 +----------------------------------- lib/arel_attribute/base.rb | 142 +++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 139 deletions(-) create mode 100644 lib/arel_attribute/base.rb diff --git a/lib/arel_attribute.rb b/lib/arel_attribute.rb index 2df38d3..fe7b411 100644 --- a/lib/arel_attribute.rb +++ b/lib/arel_attribute.rb @@ -14,143 +14,6 @@ module ArelAttribute class Error < StandardError; end - - module Base - def self.included(base) - base.extend ClassMethods - base.include InstanceMethods - # base.include ArelAttribute::ArelAggregate - # name => arel_block (lambda that takes an arel table, returns an arel node) - base.class_attribute :arel_aliases, instance_accessor: false, default: {} - # name => type (symbol like :integer, or an ActiveModel::Type instance) - base.class_attribute :arel_attribute_types, instance_accessor: false, default: {} - end - - module InstanceMethods - # Rails internals (associations, preloading) read FK/PK values via - # _read_attribute, not via the public method. For virtual-only arel - # attributes (not backed by a real column), fall back to calling the - # Ruby getter so belongs_to/has_many with virtual FKs work. - def _read_attribute(attr_name, &block) # :nodoc: - if self.class.arel_attribute?(attr_name) && !self.class.column_names.include?(attr_name.to_s) - written = @arel_attribute_values&.dig(attr_name.to_s) - return written unless written.nil? - # Only call the ruby getter if the method is explicitly defined - # (not an AR-generated attribute method, which would recurse back here). - if self.class.method_defined?(attr_name, false) || - self.class.private_method_defined?(attr_name, false) - return send(attr_name) - end - end - super - end - - # Rails associations call _write_attribute to set FK values on - # child records. For virtual-only arel attributes, store the value - # in-memory so the getter and belongs_to can resolve the parent. - def _write_attribute(attr_name, value) # :nodoc: - if self.class.arel_attribute?(attr_name) && !self.class.column_names.include?(attr_name.to_s) - @arel_attribute_values ||= {} - @arel_attribute_values[attr_name.to_s] = value - else - super - end - end - end - - module ClassMethods - # Define an attribute backed by an arel expression. - # - # The block receives the arel table and returns an arel node. - # This allows the attribute to be used in WHERE, ORDER BY, and SELECT clauses. - # - # arel_attribute :parent_id, :integer do |t| - # Arel::Nodes::NamedFunction.new('SUBSTR', [t[:path], ...]) - # end - # - # With through:/source:, delegates to an association's column and auto-generates - # the arel as a correlated subquery (works with belongs_to and has_one): - # - # arel_attribute :teacher_name, :string, through: :teacher, source: :name - # - def arel_attribute(name, type, through: nil, source: name, default: nil, &block) - if through - define_arel_delegate_method(name, source, through, default) - - unless (to_ref = reflect_on_association(through)) - raise ArgumentError, "#{self.name}.arel_attribute #{name.inspect} references unknown :through association #{through.inspect}" - end - - block ||= ArelAttribute::ArelDelegate.virtual_delegate_arel(self, source, to_ref) - end - - 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) - end - - def arel_attribute_names - arel_aliases.keys - end - - def arel_attribute?(name) - arel_aliases.key?(name.to_s) - end - - def attribute_supported_by_sql?(name) - column_names.include?(name.to_s) || arel_attribute?(name) - end - - # Override: ActiveModel::AttributeRegistration::ClassMethods#type_for_attribute - # Source: activemodel 8.1.2 lib/active_model/attribute_registration.rb:43 - # - # Returns the correct type for virtual arel attributes. This is used by - # the preloader to type-cast FK values in WHERE clauses - # (e.g. WHERE parent_id IN (1, 2) needs integer binding). - # - # We cannot use _default_attributes or attribute_types for this because: - # - _default_attributes makes Rails expect the column in DB result sets - # (causes MissingAttributeError on load) - # - attribute_types mutations are lost when @attribute_types is reset - # (e.g. reload_schema_from_cache calls reset_default_attributes!) - def type_for_attribute(attr_name, &block) - name = attr_name.to_s - if arel_attribute_types.key?(name) - resolved_arel_attribute_types[name] - else - super - end - end - - def arel_table - @arel_table ||= ArelAttribute::TableProxy.new(table_name, klass: self) - end - - private - - # Define a Ruby getter that delegates to the association, with DB-loaded value support. - def define_arel_delegate_method(name, source, through, default) - define_method(name) do - if has_attribute?(name.to_s) - self[name.to_s] || default - else - target = send(through) - target.nil? ? default : target.send(source) - end - end - 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 - @resolved_arel_attribute_types ||= arel_attribute_types.transform_values do |type| - if type.is_a?(Symbol) || type.is_a?(String) - ActiveRecord::Type.lookup(type, adapter: ActiveRecord::Type.adapter_name_from(self)) - else - type - end - end - end - end - end end + +require "arel_attribute/base" diff --git a/lib/arel_attribute/base.rb b/lib/arel_attribute/base.rb new file mode 100644 index 0000000..f2e6581 --- /dev/null +++ b/lib/arel_attribute/base.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module ArelAttribute + module Base + def self.included(base) + base.extend ClassMethods + base.include InstanceMethods + # base.include ArelAttribute::ArelAggregate + # name => arel_block (lambda that takes an arel table, returns an arel node) + base.class_attribute :arel_aliases, instance_accessor: false, default: {} + # name => type (symbol like :integer, or an ActiveModel::Type instance) + base.class_attribute :arel_attribute_types, instance_accessor: false, default: {} + end + + module InstanceMethods + # Rails internals (associations, preloading) read FK/PK values via + # _read_attribute, not via the public method. For virtual-only arel + # attributes (not backed by a real column), fall back to calling the + # Ruby getter so belongs_to/has_many with virtual FKs work. + def _read_attribute(attr_name, &block) # :nodoc: + if self.class.arel_attribute?(attr_name) && !self.class.column_names.include?(attr_name.to_s) + written = @arel_attribute_values&.dig(attr_name.to_s) + return written unless written.nil? + # Only call the ruby getter if the method is explicitly defined + # (not an AR-generated attribute method, which would recurse back here). + if self.class.method_defined?(attr_name, false) || + self.class.private_method_defined?(attr_name, false) + return send(attr_name) + end + end + super + end + + # Rails associations call _write_attribute to set FK values on + # child records. For virtual-only arel attributes, store the value + # in-memory so the getter and belongs_to can resolve the parent. + def _write_attribute(attr_name, value) # :nodoc: + if self.class.arel_attribute?(attr_name) && !self.class.column_names.include?(attr_name.to_s) + @arel_attribute_values ||= {} + @arel_attribute_values[attr_name.to_s] = value + else + super + end + end + end + + module ClassMethods + # Define an attribute backed by an arel expression. + # + # The block receives the arel table and returns an arel node. + # This allows the attribute to be used in WHERE, ORDER BY, and SELECT clauses. + # + # arel_attribute :parent_id, :integer do |t| + # Arel::Nodes::NamedFunction.new('SUBSTR', [t[:path], ...]) + # end + # + # With through:/source:, delegates to an association's column and auto-generates + # the arel as a correlated subquery (works with belongs_to and has_one): + # + # arel_attribute :teacher_name, :string, through: :teacher, source: :name + # + def arel_attribute(name, type, through: nil, source: name, default: nil, &block) + if through + define_arel_delegate_method(name, source, through, default) + + unless (to_ref = reflect_on_association(through)) + raise ArgumentError, "#{self.name}.arel_attribute #{name.inspect} references unknown :through association #{through.inspect}" + end + + block ||= ArelAttribute::ArelDelegate.virtual_delegate_arel(self, source, to_ref) + end + + 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) + end + + def arel_attribute_names + arel_aliases.keys + end + + def arel_attribute?(name) + arel_aliases.key?(name.to_s) + end + + def attribute_supported_by_sql?(name) + column_names.include?(name.to_s) || arel_attribute?(name) + end + + # Override: ActiveModel::AttributeRegistration::ClassMethods#type_for_attribute + # Source: activemodel 8.1.2 lib/active_model/attribute_registration.rb:43 + # + # Returns the correct type for virtual arel attributes. This is used by + # the preloader to type-cast FK values in WHERE clauses + # (e.g. WHERE parent_id IN (1, 2) needs integer binding). + # + # We cannot use _default_attributes or attribute_types for this because: + # - _default_attributes makes Rails expect the column in DB result sets + # (causes MissingAttributeError on load) + # - attribute_types mutations are lost when @attribute_types is reset + # (e.g. reload_schema_from_cache calls reset_default_attributes!) + def type_for_attribute(attr_name, &block) + name = attr_name.to_s + if arel_attribute_types.key?(name) + resolved_arel_attribute_types[name] + else + super + end + end + + def arel_table + @arel_table ||= ArelAttribute::TableProxy.new(table_name, klass: self) + end + + private + + # Define a Ruby getter that delegates to the association, with DB-loaded value support. + def define_arel_delegate_method(name, source, through, default) + define_method(name) do + if has_attribute?(name.to_s) + self[name.to_s] || default + else + target = send(through) + target.nil? ? default : target.send(source) + end + end + 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 + @resolved_arel_attribute_types ||= arel_attribute_types.transform_values do |type| + if type.is_a?(Symbol) || type.is_a?(String) + ActiveRecord::Type.lookup(type, adapter: ActiveRecord::Type.adapter_name_from(self)) + else + type + end + end + end + end + end +end