# typed: true # frozen_string_literal: true require "sorbet-runtime" require "active_record" require "active_record/base" require "active_support" require "active_support/concern" require "active_model/attribute_set" require_relative "has_aux_table/key_type" require_relative "has_aux_table/util" require_relative "has_aux_table/relation_extensions" require_relative "has_aux_table/aux_table_config" require_relative "has_aux_table/migration_extensions" module HasAuxTable extend T::Sig extend T::Helpers extend ActiveSupport::Concern included do T.bind(self, T.class_of(ActiveRecord::Base)) before_create do T.bind(self, ActiveRecord::Base) T.unsafe(self).type ||= self.class.name end end module ClassMethods extend T::Sig extend T::Helpers requires_ancestor { T.class_of(ActiveRecord::Base) } include RelationExtensions # Main DSL method for defining auxiliary tables sig { params(aux_name: T.any(String, Symbol)).returns(AuxTableConfig) } def aux_table(aux_name) @aux_table_configs ||= T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig])) aux_name = aux_name.to_sym if @aux_table_configs.key?(aux_name) Kernel.raise ArgumentError, "Auxiliary '#{aux_name}' on #{self.name} (table '#{self.table_name}') already exists" end @aux_table_configs[aux_name] = config = generate_aux_config(aux_name) setup_attribute_types_hook!(config) setup_load_schema_hook!(config) setup_initialize_hook!(config) setup_save_hook!(config) setup_reload_hook!(config) setup_attributes_hook!(config) setup_relation_extensions!(config) setup_attribute_getter_setter_hooks!(config) config end private # Generate auxiliary model class dynamically sig do params( aux_name: Symbol, foreign_key: KeyType, primary_key: KeyType ).returns(AuxTableConfig) end def generate_aux_config( aux_name, # The column on the aux table that points to the main table foreign_key: :base_table_id, primary_key: self.primary_key ) base_table = self.table_name aux_table_name = :"#{base_table}_#{aux_name}_aux" # Generate class name (e.g., :car_aux => "CarAux") aux_class_name = aux_table_name.to_s.camelize aux_association_name = aux_table_name.to_s.singularize.to_sym # Ensure the class name doesn't conflict with existing constants if Object.const_defined?(aux_class_name) Kernel.raise ArgumentError, "Class #{aux_class_name} already exists" end # Get the current class for the association main_class = T.cast(self, T.class_of(ActiveRecord::Base)) main_association_name = foreign_key.to_s.delete_suffix("_id").to_sym # Create the auxiliary model class aux_class = Class.new(ActiveRecord::Base) do self.table_name = aux_table_name.to_s self.primary_key = foreign_key # Define the association back to the specific STI subclass # Foreign key points to base STI table (e.g., vehicle_id) # But association is to the specific subclass (e.g., Car) self.belongs_to( main_association_name, class_name: main_class.name, foreign_key:, primary_key:, inverse_of: aux_association_name ) end # set up has_one association to the auxiliary table self.has_one( aux_association_name, class_name: aux_class_name, foreign_key:, primary_key:, inverse_of: main_association_name, dependent: :destroy ) # so the aux table is joined against the main table self.default_scope { eager_load(aux_association_name) } # Set the constant to make the class accessible Object.const_set(aux_class_name, aux_class) AuxTableConfig.new( aux_table_name:, aux_class:, main_class:, aux_association_name:, main_association_name:, foreign_key:, primary_key: ) end sig { params(config: AuxTableConfig).void } def setup_attribute_types_hook!(config) original_method = config.main_class.method(:attribute_types) config .main_class .define_singleton_method(:attribute_types) do @aux_config_attribute_types_cache ||= T.let( {}, T.nilable( T::Hash[Symbol, T::Hash[String, ActiveModel::Type::Value]] ) ) @aux_config_attribute_types_cache[config.aux_table_name] ||= begin original_types = T.let( original_method.call, T::Hash[String, ActiveModel::Type::Value] ) # move 'created_at', 'updated_at' etc to the end of the list timestamp_types = {} original_types.reject! do |k, v| if k.end_with?("_at") && v.type == :datetime timestamp_types[k] = v original_types.delete(k) end end original_types.merge!(config.aux.attribute_types) original_types.merge!(timestamp_types) original_types end end config.main_class.attributes_for_inspect = Util.attributes_for_inspect(config) end # Hook into schema loading to generate attribute accessors when schema is loaded sig { params(config: AuxTableConfig).void } def setup_load_schema_hook!(config) # Override load_schema to also generate auxiliary attribute accessors when schema is loaded load_schema_method = self.method(:load_schema!) self.define_singleton_method(:load_schema!) do T.bind( self, T.all(T.class_of(ActiveRecord::Base), HasAuxTable::ClassMethods) ) aux_config_load_schema!(load_schema_method, config) end self.load_schema! if self.schema_loaded? end sig { params(load_schema_method: Method, config: AuxTableConfig).void } def aux_config_load_schema!(load_schema_method, config) # first, load the main and aux table schemas like normal result = load_schema_method.call config.load_aux_schema aux_table_name = config.aux_table_name check_for_overlapping_columns!( aux_table_name, config.main.column_names, config.aux.column_names ) # set attributes that exist on the aux table to also exist on this table config.aux.default_attributes.each do |name, attr| @default_attributes[name] = attr end # Generate attribute accessors for each auxiliary column config.aux.columns_hash.each do |column_name, column| column_name = column_name.to_sym if self.method_defined?(column_name.to_sym) raise "invariant: method #{column_name} already defined" end config.define_aux_attribute_delegate(column_name) config.define_aux_attribute_delegate(:"#{column_name}?") config.define_aux_attribute_delegate(:"#{column_name}=") end result end sig { params(config: AuxTableConfig).void } def setup_attribute_getter_setter_hooks!(config) %i[ _read_attribute read_attribute _write_attribute write_attribute _assign_attribute ].each do |method_name| method = self.instance_method(method_name) self.define_method(method_name) do |name, *args, **kwargs, &block| T.bind(self, ActiveRecord::Base) if config.aux.column_names.include?(name) target = config.aux_model_for(self) T.unsafe(target).send(method_name, name, *args, **kwargs, &block) else T.unsafe(method).bind(self).call(name, *args, **kwargs, &block) end end end end sig { params(config: AuxTableConfig).void } def setup_initialize_hook!(config) initialize_method = self.instance_method(:initialize) self.define_method(:initialize) do |args, **kwargs, &block| T.bind(self, ActiveRecord::Base) main_args, aux_args = config.aux.partition_by_columns(args) initialize_method.bind(self).call(main_args, **kwargs, &block) config.aux_model_for(self).assign_attributes(aux_args) end end sig { params(config: AuxTableConfig).void } def setup_save_hook!(config) %i[save save!].each do |method_name| save_method = self.instance_method(method_name) self.define_method(method_name) do |*args, **kwargs, &block| T.bind(self, ActiveRecord::Base) result = save_method.bind(self).call(*args, **kwargs, &block) result &&= self .association(config.aux_association_name) .target .send(method_name, *args, **kwargs, &block) result end end end sig { params(config: AuxTableConfig).void } def setup_reload_hook!(config) self.define_method(:reload) do |*args| T.bind(self, ActiveRecord::Base) aux_model = config.aux_model_for(self) fresh_model = self.class.find(id) @attributes = fresh_model.instance_variable_get(:@attributes) aux_model.instance_variable_set( :@attributes, fresh_model .association(config.aux_association_name) .target .instance_variable_get(:@attributes) ) self end end sig { params(config: AuxTableConfig).void } def setup_attributes_hook!(config) attributes_method = self.instance_method(:attributes) self.define_method(:attributes) do |*args| T.bind(self, ActiveRecord::Base) ret = attributes_method.bind(self).call(*args) target = config.aux_model_for(self) ret.merge!(config.aux.attributes_on(target)) ret end end sig do params( aux_table_name: Symbol, main_columns: T::Array[String], aux_columns: T::Array[String] ).void end def check_for_overlapping_columns!( aux_table_name, main_columns, aux_columns ) # Find overlapping columns (excluding system columns and foreign keys) overlapping_columns = aux_columns.select { |col| main_columns.include?(col) } if overlapping_columns.any? column_list = overlapping_columns.map { |col| "'#{col}'" }.join(", ") Kernel.raise ArgumentError, "Auxiliary table '#{aux_table_name}' defines column(s) #{column_list} " \ "that already exist(s) in main table '#{self.table_name}'. " \ "Auxiliary table columns must not overlap with main table columns." end end end mixes_in_class_methods(ClassMethods) end