From fda95fb33f51188a233e34b0ce9317059215b793 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Tue, 15 Jul 2025 07:22:38 +0000 Subject: [PATCH] more rspec --- demo_functionality.rb | 2 + lib/has_aux_table.rb | 62 ++++++++++++++------ lib/has_aux_table/aux_table_config.rb | 54 ++++++++++-------- lib/has_aux_table/query_extensions.rb | 73 ------------------------ lib/has_aux_table/relation_extensions.rb | 36 ++++-------- lib/has_aux_table/util.rb | 39 +++++++++++++ spec/active_record/aux_table_spec.rb | 10 ++++ 7 files changed, 136 insertions(+), 140 deletions(-) delete mode 100644 lib/has_aux_table/query_extensions.rb create mode 100644 lib/has_aux_table/util.rb diff --git a/demo_functionality.rb b/demo_functionality.rb index 497348a..cabe1f0 100755 --- a/demo_functionality.rb +++ b/demo_functionality.rb @@ -107,6 +107,8 @@ class E621Post < Post belongs_to :creator, class_name: "E621User", inverse_of: :created_posts end +puts FaPost.inspect + fa_user = FaUser.create!(username: "Alice", url_name: "alice") fa_user_id = fa_user.id raise if fa_user.id.nil? diff --git a/lib/has_aux_table.rb b/lib/has_aux_table.rb index 749cc68..28670eb 100644 --- a/lib/has_aux_table.rb +++ b/lib/has_aux_table.rb @@ -8,9 +8,9 @@ require "active_support" require "active_support/concern" require "active_model/attribute_set" +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/query_extensions" require_relative "has_aux_table/migration_extensions" module HasAuxTable @@ -22,7 +22,6 @@ module HasAuxTable module ClassMethods extend T::Sig - include QueryExtensions include RelationExtensions # Main DSL method for defining auxiliary tables @@ -41,8 +40,9 @@ module HasAuxTable @aux_table_configs[aux_table_name] = aux_config = generate_aux_config(aux_table_name) + setup_attribute_types_hook!(aux_config) + setup_columns_hook!(aux_config) setup_schema_loading_hook!(aux_config) - # setup_query_extensions!(self, aux_config, with_bind_attribute: false) setup_relation_extensions!(aux_config) aux_config @@ -60,6 +60,42 @@ module HasAuxTable 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.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 + + sig { params(aux_config: AuxTableConfig).void } + def setup_columns_hook!(aux_config) + # original_method = aux_config.main_class.method(:columns) + # aux_config + # .main_class + # .define_singleton_method(:columns) do + # original_columns = original_method.call + # aux_columns = + # aux_config.model_class.columns.filter do |col| + # aux_config.is_aux_column?(col.name) + # end + # original_columns + aux_columns + # 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) @@ -72,13 +108,10 @@ module HasAuxTable result = load_schema_method.call aux_config.load_aux_schema - # `columns_hash` is populated by `load_schema!` so we can use it to - # validate no column overlaps between main table and auxiliary table main_columns_hash = self.columns_hash aux_columns_hash = - aux_config.model_class.columns_hash.reject do |col| - %w[id created_at updated_at].include?(col) || - col == aux_config.foreign_key.to_s + aux_config.model_class.columns_hash.select do |col| + aux_config.is_aux_column?(col) end main_column_names = main_columns_hash.keys @@ -99,6 +132,7 @@ module HasAuxTable 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 @@ -210,7 +244,7 @@ module HasAuxTable # Get the base class name for the foreign key (e.g., Vehicle -> vehicle_id) # In STI, all subclasses share the same table, so we need the base class - foreign_key = "base_table_id".to_sym + foreign_key = :base_table_id # Get the current class for the association main_class = self @@ -222,7 +256,7 @@ module HasAuxTable Class.new(ActiveRecord::Base) do # Set the table name self.table_name = table_name.to_s - self.primary_key = "base_table_id" + 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) @@ -246,15 +280,9 @@ module HasAuxTable dependent: :destroy ) + # so the aux table is joined against the main table self.default_scope { eager_load(aux_association_name) } - after_create do - aux_model = association(aux_association_name).target - aux_model.base_table_id = self.id - aux_model.save! - true - end - # Set the constant to make the class accessible Object.const_set(aux_class_name, aux_class) diff --git a/lib/has_aux_table/aux_table_config.rb b/lib/has_aux_table/aux_table_config.rb index 4d2d876..12e3c97 100644 --- a/lib/has_aux_table/aux_table_config.rb +++ b/lib/has_aux_table/aux_table_config.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true module HasAuxTable - # AuxTable class to store auxiliary table definition class AuxTableConfig < T::Struct extend T::Sig @@ -61,7 +60,8 @@ module HasAuxTable ).returns(ActiveRecord::Relation) end def apply_split_conditions!(relation, conditions) - main_conditions, aux_conditions = self.split_conditions(conditions) + main_conditions, aux_conditions = + self.partition_by_aux_columns(conditions) relation = relation.where(main_conditions) if main_conditions.any? if aux_conditions.any? relation = relation.where(aux_association_name => aux_conditions) @@ -69,22 +69,13 @@ module HasAuxTable relation end - sig do - params(conditions: T::Hash[String, T.untyped]).returns( - [T::Hash[String, T.untyped], T::Hash[String, T.untyped]] - ) - end - def split_conditions(conditions) - conditions.partition { |k, _| !self.is_aux_column?(k) }.map(&:to_h) - end - sig do params(conditions: T::Hash[String, T.untyped]).returns( T::Hash[String, T.untyped] ) end def remap_conditions(conditions) - main, aux = split_conditions(conditions) + main, aux = partition_by_aux_columns(conditions) main.merge!(aux_association_name => aux) if aux.any? main end @@ -100,25 +91,38 @@ module HasAuxTable aux_model.assign_attributes(aux_args) end - sig { returns(T::Array[Symbol]) } + sig { returns(T::Array[String]) } def aux_column_names @aux_column_names ||= - model_class - .column_names - .reject do |col| - [ - self.foreign_key, - :base_table_id, - :created_at, - :updated_at - ].flatten.include?(col.to_sym) - end - .map(&:to_sym) + begin + rejected_columns = [ + self.foreign_key, + self.primary_key, + "created_at", + "updated_at" + ].flatten.map(&:to_s) + + model_class + .column_names + .reject { |col| rejected_columns.include?(col.to_s) } + .map(&:to_s) + end end sig { params(name: T.any(Symbol, String)).returns(T::Boolean) } def is_aux_column?(name) - aux_column_names.include?(name.to_sym) + aux_column_names.include?(name.to_s) + end + + private + + sig do + params(hash: T::Hash[String, T.untyped]).returns( + [T::Hash[String, T.untyped], T::Hash[String, T.untyped]] + ) + end + def partition_by_aux_columns(hash) + hash.partition { |k, _| !self.is_aux_column?(k) }.map(&:to_h) end end end diff --git a/lib/has_aux_table/query_extensions.rb b/lib/has_aux_table/query_extensions.rb deleted file mode 100644 index 452f0db..0000000 --- a/lib/has_aux_table/query_extensions.rb +++ /dev/null @@ -1,73 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module HasAuxTable - module QueryExtensions - extend T::Sig - sig do - params( - on: T.any(ActiveRecord::Relation, T.class_of(ActiveRecord::Base)), - aux_config: AuxTableConfig, - with_bind_attribute: T::Boolean - ).void - end - def setup_query_extensions!(on, aux_config, with_bind_attribute: true) - # association_name = aux_config.aux_association_name - - where_method = on.method(:where) - on.define_singleton_method(:where) do |args| - if args.is_a?(Hash) - main_conditions, aux_conditions = aux_config.split_conditions(args) - combined_conditions = - main_conditions.merge( - aux_config.aux_association_name => aux_conditions - ) - where_method.call(combined_conditions) - else - super(args) - end - end - - all_method = on.method(:all) - on.define_singleton_method(:all) do - all_method.call.eager_load(aux_config.aux_association_name) - end - - unscoped_method = on.method(:unscoped) - on.define_singleton_method(:unscoped) do - relation = unscoped_method.call - self.setup_query_extensions!(relation, aux_config) - relation - end - - if with_bind_attribute - bind_attribute_method = on.method(:bind_attribute) - on.define_singleton_method(:bind_attribute) do |name, value, &block| - if aux_config.is_aux_column?(name) - aux_config.aux_bind_attribute(name, value, &block) - else - bind_attribute_method.call(name, value, &block) - end - end - end - - find_by_method = on.method(:find_by) - on.define_singleton_method(:find_by) do |args| - main_conditions, aux_conditions = aux_config.split_conditions(args) - combined_conditions = main_conditions - if aux_conditions.any? - combined_conditions.merge!( - aux_config.aux_association_name => aux_conditions - ) - end - find_by_method.call(combined_conditions) - end - - on.define_singleton_method(:exists?) do |args| - relation = self.select("1").joins(aux_config.aux_association_name) - relation = aux_config.apply_split_conditions!(relation, args) - relation.first.present? - end - end - end -end diff --git a/lib/has_aux_table/relation_extensions.rb b/lib/has_aux_table/relation_extensions.rb index a0cf258..a6463b1 100644 --- a/lib/has_aux_table/relation_extensions.rb +++ b/lib/has_aux_table/relation_extensions.rb @@ -4,36 +4,18 @@ module HasAuxTable module RelationExtensions extend T::Sig + Util = HasAuxTable::Util sig { params(aux_config: AuxTableConfig).void } def setup_relation_extensions!(aux_config) setup_main_class_extensions!(aux_config) end - def hook_method(target, method_name, is_instance_method, &hook_block) - define_method = - is_instance_method ? :define_method : :define_singleton_method - - target_method = - ( - if is_instance_method - target.instance_method(method_name) - else - target.method(method_name) - end - ) - - target.send(define_method, method_name) do |*args, **kwargs, &block| - method = is_instance_method ? target_method.bind(self) : target_method - hook_block.call(method, *args, **kwargs, &block) - end - end - sig { params(aux_config: AuxTableConfig).void } def setup_main_class_extensions!(aux_config) main_class = aux_config.main_class - hook_method(main_class, :where, false) do |original, *args| + Util.hook_method(main_class, :where, false) do |original, *args| if args.length == 1 && args.first.is_a?(Hash) opts_remapped = aux_config.remap_conditions(args.first) original.call(opts_remapped) @@ -42,7 +24,7 @@ module HasAuxTable end end - hook_method( + Util.hook_method( main_class, :all, false @@ -52,20 +34,24 @@ module HasAuxTable ) end - hook_method(main_class, :unscoped, false) do |original, *args, **kwargs| + Util.hook_method( + main_class, + :unscoped, + false + ) do |original, *args, **kwargs| original.call(*args, **kwargs).eager_load( aux_config.aux_association_name ) end - hook_method(main_class, :find, false) do |original, arg| + Util.hook_method(main_class, :find, false) do |original, arg| original.call(arg) end relation_class = main_class.relation_delegate_class(ActiveRecord::Relation) - hook_method(relation_class, :where!, true) do |original, opts, *rest| + Util.hook_method(relation_class, :where!, true) do |original, opts, *rest| if opts.is_a?(Hash) opts_remapped = aux_config.remap_conditions(opts) original.call(opts_remapped, *rest) @@ -74,7 +60,7 @@ module HasAuxTable end end - hook_method( + Util.hook_method( relation_class, :bind_attribute, true diff --git a/lib/has_aux_table/util.rb b/lib/has_aux_table/util.rb new file mode 100644 index 0000000..f1693c6 --- /dev/null +++ b/lib/has_aux_table/util.rb @@ -0,0 +1,39 @@ +# typed: true +# frozen_string_literal: true + +module HasAuxTable + module Util + extend T::Sig + + sig do + params( + target: + T.any( + T.class_of(ActiveRecord::Base), + T.class_of(ActiveRecord::Relation) + ), + method_name: Symbol, + is_instance_method: T::Boolean, + hook_block: T.proc.void + ).void + end + def self.hook_method(target, method_name, is_instance_method, &hook_block) + define_method = + is_instance_method ? :define_method : :define_singleton_method + + target_method = + ( + if is_instance_method + target.instance_method(method_name) + else + target.method(method_name) + end + ) + + target.send(define_method, method_name) do |*args, **kwargs, &block| + method = is_instance_method ? target_method.bind(self) : target_method + T.unsafe(hook_block).call(method, *args, **kwargs, &block) + end + end + end +end diff --git a/spec/active_record/aux_table_spec.rb b/spec/active_record/aux_table_spec.rb index bf1929a..2579d03 100644 --- a/spec/active_record/aux_table_spec.rb +++ b/spec/active_record/aux_table_spec.rb @@ -129,6 +129,16 @@ RSpec.describe HasAuxTable do expect(Vehicle.count).to eq(0) end + describe "column reporting" do + it "reports the correct columns on the string repr of the class" do + expect(Car.inspect).to include("fuel_type") + end + + it "does not include the aux table foreign key" do + expect(Car.inspect).not_to include("base_table_id") + end + end + describe "database integration" do it "provides automatic attribute accessors for auxiliary table columns" do vehicle = Car.create!(name: "Honda Civic")