# 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/version" 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/model_class_helper" 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 sig do params(column_name: T.any(String, Symbol)).returns( T.nilable(AuxTableConfig) ) end def aux_table_for(column_name) @aux_table_configs ||= T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig])) @aux_table_configs.values.find do |config| config.aux.is_column?(column_name) end end # Main DSL method for defining auxiliary tables sig do params( aux_name: T.any(String, Symbol), allow_redefining: T.nilable(T.any(Symbol, T::Array[Symbol])) ).returns(AuxTableConfig) end def aux_table(aux_name, allow_redefining: nil) @aux_table_configs ||= T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig])) allow_redefining = [allow_redefining].flatten.compact 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, allow_redefining) setup_attribute_types_hook!(config) setup_load_schema_hook!(config) setup_initialize_hook!(config) setup_changed_hook!(config) setup_save_hook!(config) setup_attributes_hook!(config) setup_relation_extensions!(config) setup_attribute_getter_setter_hooks!(config) setup_enum_hook!(config) setup_update_counter_hook!(config) config end sig { params(aux_name: Symbol).returns(T.nilable(AuxTableConfig)) } def aux_table_config(aux_name) @aux_table_configs&.[](aux_name) end private # Generate auxiliary model class dynamically sig do params(aux_name: Symbol, allow_redefining: T::Array[Symbol]).returns( AuxTableConfig ) end def generate_aux_config(aux_name, allow_redefining) main_class = T.cast(self, T.class_of(ActiveRecord::Base)) main_table = main_class.table_name aux_table_name = :"#{main_table}_#{aux_name}_aux" aux_class_name = aux_table_name.to_s.camelize aux_association_name = :"#{aux_name}_aux" aux_class = Class.new(ActiveRecord::Base) do self.table_name = aux_table_name.to_s self.primary_key = :base_table_id end if Object.const_defined?(aux_class_name) Object.send(:remove_const, aux_class_name) end Object.const_set(aux_class_name, aux_class) aux_table_config = AuxTableConfig.from_models( main_class:, aux_class:, aux_association_name:, allow_redefining: ) # 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) aux_class.belongs_to( :main, class_name: main_class.name, foreign_key: aux_class.primary_key, primary_key: main_class.primary_key, inverse_of: aux_association_name ) # set up has_one association to the auxiliary table self.has_one( aux_association_name, class_name: aux_class_name, foreign_key: aux_class.primary_key, primary_key: main_class.primary_key, inverse_of: :main, dependent: :destroy ) # so the aux table is joined against the main table self.default_scope { eager_load(aux_association_name) } aux_table_config end sig { params(config: AuxTableConfig).void } def setup_attribute_types_hook!(config) original_method = config.main.klass.method(:attribute_types) config .main .klass .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.klass.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 config.load_aux_schema result = load_schema_method.call 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) && !config.allow_redefining.include?(column_name.to_sym) raise "invariant: method #{column_name} already defined" end [ "", "_in_database", "?", "=", "_changed?", "_change", %w[clear_ _change] ].each do |mod| prefix, suffix = mod.is_a?(Array) ? mod : ["", mod] config.define_aux_attribute_delegate( :"#{prefix}#{column_name}#{suffix}" ) end end result end sig { params(config: AuxTableConfig).void } def setup_attribute_getter_setter_hooks!(config) %i[ _read_attribute read_attribute _write_attribute write_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.to_s) target = config.aux_model_for(self) ret = T.unsafe(target).send(method_name, name, *args, **kwargs, &block) ret 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) if args && args.size == 1 && (arg = args.first).is_a?(Hash) main_args, aux_args = config.partition_by_columns(args.first) initialize_method.bind(self).call(main_args, **kwargs, &block) config.aux_model_for(self).assign_attributes(aux_args) else initialize_method.bind(self).call(*args, **kwargs, &block) config.aux_model_for(self) end end end sig { params(config: AuxTableConfig).void } def setup_changed_hook!(config) changed_method = self.instance_method(:changed?) self.define_method(:changed?) do T.bind(self, ActiveRecord::Base) changed_method.bind(self).call || config.aux_model_for(self).changed? end changed_attributes_method = self.instance_method(:changed_attributes) self.define_method(:changed_attributes) do T.bind(self, ActiveRecord::Base) changed_attributes_method .bind(self) .call .merge(config.aux_model_for(self).changed_attributes) 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_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 sig { params(config: AuxTableConfig).void } def setup_enum_hook!(config) self.define_singleton_method( :enum ) do |name = nil, values = nil, **options, &block| name = name.to_s config.aux.klass.enum(name, values, **options, &block) self.defined_enums.merge!(config.aux.klass.defined_enums) # define methods on the main class that delegate to the aux class config .aux .klass .send(:_enum_methods_module) .instance_methods .each do |method_name| self ._enum_methods_module .define_method(method_name) do |*args, **kwargs, &block| raise "not implemented" end end define_singleton_method(name.pluralize) do config.aux.klass.send(name.pluralize) end values.keys.each do |value_name| value_name = [options[:prefix], value_name, options[:suffix]].reject( &:blank? ).join("_") [ value_name, "#{value_name}?", "#{value_name}!", "#{value_name}=" ].each do |method_name| config.define_aux_attribute_delegate(method_name.to_sym) end end end end sig { params(config: AuxTableConfig).void } def setup_update_counter_hook!(config) self.define_singleton_method(:update_counters) do |id, counters| T.bind(self, T.class_of(ActiveRecord::Base)) main_counters = {} aux_counters = {} opts = {} counters.each do |k, v| is_aux = config.aux.is_column?(k) is_main = config.main.is_column?(k) if !is_aux && !is_main opts[k] = v elsif is_aux aux_counters[k] = v elsif is_main main_counters[k] = v end end super(id, main_counters.merge(opts)) if main_counters.any? if aux_counters.any? config.aux.klass.update_counters(id, aux_counters.merge(opts)) end end end end mixes_in_class_methods(ClassMethods) end