From 502b9cb5fe9c82b7436539aeb90c179478287a4f Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Thu, 17 Jul 2025 18:27:57 +0000 Subject: [PATCH] inspect attributes --- demo_functionality.rb | 2 + ...aux_table.gemspec => has-aux-table.gemspec | 0 lib/has_aux_table.rb | 361 +++++++++--------- lib/has_aux_table/aux_table_config.rb | 26 +- lib/has_aux_table/util.rb | 37 +- spec/active_record/aux_table_spec.rb | 19 + 6 files changed, 235 insertions(+), 210 deletions(-) rename has_aux_table.gemspec => has-aux-table.gemspec (100%) diff --git a/demo_functionality.rb b/demo_functionality.rb index cabe1f0..33a376b 100755 --- a/demo_functionality.rb +++ b/demo_functionality.rb @@ -116,6 +116,8 @@ raise unless fa_user.persisted? raise unless fa_user.username == "Alice" raise unless fa_user.url_name == "alice" +fa_user.reload + fa_user_found = FaUser.find_by(username: "Alice") raise unless fa_user_found.id == fa_user_id raise unless fa_user_found.username == "Alice" diff --git a/has_aux_table.gemspec b/has-aux-table.gemspec similarity index 100% rename from has_aux_table.gemspec rename to has-aux-table.gemspec diff --git a/lib/has_aux_table.rb b/lib/has_aux_table.rb index 92ca183..d5f6869 100644 --- a/lib/has_aux_table.rb +++ b/lib/has_aux_table.rb @@ -29,6 +29,7 @@ module HasAuxTable requires_ancestor { T.class_of(ActiveRecord::Base) } requires_ancestor { ActiveRecord::ModelSchema::ClassMethods } requires_ancestor { ActiveRecord::Associations::ClassMethods } + requires_ancestor { ActiveModel::Attributes::ClassMethods } include RelationExtensions @@ -48,179 +49,13 @@ module HasAuxTable setup_attribute_types_hook!(aux_config) setup_schema_loading_hook!(aux_config) setup_relation_extensions!(aux_config) + setup_attribute_getter_setter_hooks!(aux_config) aux_config end private - sig { params(aux_config: AuxTableConfig).void } - def setup_attribute_types_hook!(aux_config) - original_method = aux_config.main_class.method(:attribute_types) - aux_config - .main_class - .define_singleton_method(:attribute_types) do - @aux_config_attribute_types_cache ||= {} - @aux_config_attribute_types_cache[aux_config.aux_table_name] ||= begin - original_types = original_method.call.dup - - aux_types = - aux_config.model_class.attribute_types.filter do |k, _| - aux_config.is_aux_column?(k) - end - - original_types.merge!(aux_types) - original_types - end - end - end - - # Hook into schema loading to generate attribute accessors when schema is loaded - sig { params(aux_config: AuxTableConfig).void } - def setup_schema_loading_hook!(aux_config) - aux_table_name = aux_config.aux_table_name - - # 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) - ) - - # first, load the main and aux table schemas like normal - result = load_schema_method.call - aux_config.load_aux_schema - - main_columns_hash = self.columns_hash - aux_columns_hash = - aux_config.model_class.columns_hash.select do |col| - aux_config.is_aux_column?(col) - end - - main_column_names = main_columns_hash.keys - aux_column_names = aux_columns_hash.keys - - check_for_overlapping_columns!( - aux_table_name, - main_column_names, - aux_column_names - ) - - aux_attributes = aux_config.model_class._default_attributes - aux_table_filtered_attributes = - aux_attributes - .keys - .filter_map do |k| - [k, aux_attributes[k]] if aux_column_names.include?(k) - end - .to_h - - # set attributes that exist on the aux table to also exist on this table - aux_table_filtered_attributes.each do |name, attr| - @default_attributes[name] = attr - end - - # Generate attribute accessors for each auxiliary column - 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 - - aux_config.define_aux_attribute_delegate(self, column_name) - aux_config.define_aux_attribute_delegate(self, :"#{column_name}?") - aux_config.define_aux_attribute_delegate(self, :"#{column_name}=") - end - - %i[save save!].each do |method_name| - save_method = self.instance_method(method_name) - self.define_method(method_name) do |*args, **kwargs| - T.bind(self, ActiveRecord::Base) - result = save_method.bind(self).call(*args, **kwargs) - result &&= - self - .association(aux_config.aux_association_name) - .target - .send(method_name, *args, **kwargs) - result - end - end - - %i[ - _read_attribute - read_attribute - _write_attribute - write_attribute - ].each do |method_name| - read_attribute_method = self.instance_method(method_name) - self.define_method(method_name) do |name, *args, **kwargs| - T.bind(self, ActiveRecord::Base) - if aux_config.is_aux_column?(name) - target = aux_config.ensure_aux_target(self) - T.unsafe(target).send(method_name, name, *args, **kwargs) - else - T - .unsafe(read_attribute_method) - .bind(self) - .call(name, *args, **kwargs) - end - end - end - - initialize_method = self.instance_method(:initialize) - self.define_method(:initialize) do |args| - T.bind(self, ActiveRecord::Base) - aux_args, main_args = - args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h) - initialize_method.bind(self).call(main_args) - aux_config.assign_aux_attributes(self, aux_args) - end - - self.define_method(:reload) do |*args| - T.bind(self, ActiveRecord::Base) - aux_model = aux_config.ensure_aux_target(self) - fresh_model = self.class.find(id) - @attributes = fresh_model.instance_variable_get(:@attributes) - aux_model.instance_variable_set( - :@attributes, - fresh_model - .association(aux_config.aux_association_name) - .target - .instance_variable_get(:@attributes) - ) - self - end - - result - 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 - # Generate auxiliary model class dynamically sig do params( @@ -235,8 +70,6 @@ module HasAuxTable foreign_key: :base_table_id, primary_key: self.primary_key ) - T.bind(self, T.all(T.class_of(ActiveRecord::Base), Class)) - base_table = self.table_name aux_table_name = :"#{base_table}_#{aux_name}_aux" @@ -250,7 +83,7 @@ module HasAuxTable end # Get the current class for the association - main_class = self + 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 @@ -297,6 +130,194 @@ module HasAuxTable primary_key: ) end + + sig { params(aux_config: AuxTableConfig).void } + def setup_attribute_types_hook!(aux_config) + original_method = aux_config.main_class.method(:attribute_types) + aux_config + .main_class + .define_singleton_method(:attribute_types) do + @aux_config_attribute_types_cache ||= {} + @aux_config_attribute_types_cache[aux_config.aux_table_name] ||= begin + original_types = original_method.call.dup + + aux_types = + aux_config.model_class.attribute_types.filter do |k, _| + aux_config.is_aux_column?(k) + end + + # move 'created_at', 'updated_at' etc to the end of the list + at_types = {} + original_types.each do |k, v| + if k.end_with?("_at") && v.type == :datetime + at_types[k] = v + original_types.delete(k) + end + end + + original_types.merge!(aux_types) + original_types.merge!(at_types) + original_types + end + end + + aux_config.main_class.attributes_for_inspect = + Util.attributes_for_inspect(aux_config) + end + + # Hook into schema loading to generate attribute accessors when schema is loaded + sig { params(aux_config: AuxTableConfig).void } + def setup_schema_loading_hook!(aux_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, aux_config) + end + + self.load_schema! if self.schema_loaded? + end + + sig { params(load_schema_method: Method, aux_config: AuxTableConfig).void } + def aux_config_load_schema!(load_schema_method, aux_config) + # first, load the main and aux table schemas like normal + result = load_schema_method.call + aux_config.load_aux_schema + + aux_table_name = aux_config.aux_table_name + + main_columns_hash = self.columns_hash + aux_columns_hash = + aux_config.model_class.columns_hash.select do |col| + aux_config.is_aux_column?(col) + end + + main_column_names = main_columns_hash.keys + aux_column_names = aux_columns_hash.keys + + check_for_overlapping_columns!( + aux_table_name, + main_column_names, + aux_column_names + ) + + aux_attributes = aux_config.model_class._default_attributes + aux_table_filtered_attributes = + aux_attributes + .keys + .filter_map do |k| + [k, aux_attributes[k]] if aux_column_names.include?(k) + end + .to_h + + # set attributes that exist on the aux table to also exist on this table + aux_table_filtered_attributes.each do |name, attr| + @default_attributes[name] = attr + end + + # Generate attribute accessors for each auxiliary column + 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 + + aux_config.define_aux_attribute_delegate(column_name) + aux_config.define_aux_attribute_delegate(:"#{column_name}?") + aux_config.define_aux_attribute_delegate(:"#{column_name}=") + end + + %i[save save!].each do |method_name| + save_method = self.instance_method(method_name) + self.define_method(method_name) do |*args, **kwargs| + T.bind(self, ActiveRecord::Base) + result = save_method.bind(self).call(*args, **kwargs) + result &&= + self + .association(aux_config.aux_association_name) + .target + .send(method_name, *args, **kwargs) + result + end + end + + initialize_method = self.instance_method(:initialize) + self.define_method(:initialize) do |args| + T.bind(self, ActiveRecord::Base) + aux_args, main_args = + args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h) + initialize_method.bind(self).call(main_args) + aux_config.assign_aux_attributes(self, aux_args) + end + + self.define_method(:reload) do |*args| + T.bind(self, ActiveRecord::Base) + aux_model = aux_config.ensure_aux_target(self) + fresh_model = self.class.find(id) + @attributes = fresh_model.instance_variable_get(:@attributes) + aux_model.instance_variable_set( + :@attributes, + fresh_model + .association(aux_config.aux_association_name) + .target + .instance_variable_get(:@attributes) + ) + self + end + + result + end + + sig { params(aux_config: AuxTableConfig).void } + def setup_attribute_getter_setter_hooks!(aux_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 aux_config.is_aux_column?(name) + target = aux_config.ensure_aux_target(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 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 diff --git a/lib/has_aux_table/aux_table_config.rb b/lib/has_aux_table/aux_table_config.rb index 52dc14e..92274ab 100644 --- a/lib/has_aux_table/aux_table_config.rb +++ b/lib/has_aux_table/aux_table_config.rb @@ -45,24 +45,16 @@ module HasAuxTable block.call(arel_attr, aux_bind) end - sig do - params( - main_class: - T.all( - T.class_of(ActiveRecord::Base), - ActiveRecord::Associations::ClassMethods, - Module - ), - method_name: Symbol - ).void - end - def define_aux_attribute_delegate(main_class, method_name) + sig { params(method_name: Symbol).void } + def define_aux_attribute_delegate(method_name) aux_config = self - main_class.define_method(method_name) do |*args, **kwargs| - T.bind(self, ActiveRecord::Base) - aux_model = aux_config.ensure_aux_target(self) - T.unsafe(aux_model).public_send(method_name, *args, **kwargs) - end + aux_config + .main_class + .define_method(method_name) do |*args, **kwargs| + T.bind(self, ActiveRecord::Base) + aux_model = aux_config.ensure_aux_target(self) + T.unsafe(aux_model).public_send(method_name, *args, **kwargs) + end end sig do diff --git a/lib/has_aux_table/util.rb b/lib/has_aux_table/util.rb index 19d38ef..2653dc7 100644 --- a/lib/has_aux_table/util.rb +++ b/lib/has_aux_table/util.rb @@ -36,29 +36,20 @@ module HasAuxTable end end - # sig do - # type_parameters(:T) - # .params( - # obj: T::Class[T.type_parameter(:T)], - # name: Symbol, - # block: T.proc.bind(:T).void - # ) - # .returns(Symbol) - # end - # def self.safe_define_method(obj, name, &block) - # end + sig do + params(aux_config: HasAuxTable::AuxTableConfig).returns(T::Array[String]) + end + def self.attributes_for_inspect(aux_config) + main_class = aux_config.main_class - # sig do - # type_parameters(:T) - # .params( - # obj: T::Class[T.type_parameter(:T)], - # name: Symbol, - # block: T.proc.bind(T.attached_class).void - # ) - # .returns(Symbol) - # end - # def self.safe_define_singleton_method(obj, name, &block) - # Module.safe_define_singleton_method(name, &block) - # end + main_class_attributes = + if main_class.attributes_for_inspect == :all + main_class.attribute_names + else + main_class.attributes_for_inspect + end + + main_class_attributes + end end end diff --git a/spec/active_record/aux_table_spec.rb b/spec/active_record/aux_table_spec.rb index a012d94..34ee94e 100644 --- a/spec/active_record/aux_table_spec.rb +++ b/spec/active_record/aux_table_spec.rb @@ -165,6 +165,25 @@ RSpec.describe HasAuxTable do it "does not include the aux table foreign key" do expect(Car.inspect).not_to include("base_table_id") end + + it "reports created_at, updated_at timestamp columns at the end of the list" do + expect(Car.inspect).to match(/\bfuel_type\b.+\bupdated_at\b/) + expect(Car.inspect).to match(/\bname\b.+\bupdated_at\b/) + end + + it "includes columns in instances of the model" do + car = Car.create!(name: "Honda Civic") + expect(car.inspect).to include("fuel_type") + expect(car.inspect).to include("engine_size") + expect(car.inspect).to include("created_at") + expect(car.inspect).to include("updated_at") + end + + it "puts _at columns at the end of the list on instances" do + car = Car.create!(name: "Honda Civic") + expect(car.inspect).to match(/\bfuel_type\b.+\bupdated_at\b/) + expect(car.inspect).to match(/\bname\b.+\bupdated_at\b/) + end end describe "database integration" do