diff --git a/Gemfile.lock b/Gemfile.lock index f9334ca..8ef0d65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active-record-aux-table (0.1.0) + has_aux_table (0.1.0) activerecord (>= 7.0) activesupport (>= 7.0) sorbet-runtime (~> 0.5) @@ -141,9 +141,9 @@ PLATFORMS aarch64-linux DEPENDENCIES - active-record-aux-table! bundler-audit debug + has_aux_table! lefthook pry rake (~> 13.0) diff --git a/lib/has_aux_table.rb b/lib/has_aux_table.rb index e5811f1..5f767ce 100644 --- a/lib/has_aux_table.rb +++ b/lib/has_aux_table.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "sorbet-runtime" @@ -24,6 +24,12 @@ module HasAuxTable module ClassMethods extend T::Sig extend T::Helpers + requires_ancestor { Kernel } + requires_ancestor { T.class_of(BasicObject) } + requires_ancestor { T.class_of(ActiveRecord::Base) } + requires_ancestor { ActiveRecord::ModelSchema::ClassMethods } + requires_ancestor { ActiveRecord::Associations::ClassMethods } + include RelationExtensions # Main DSL method for defining auxiliary tables @@ -32,34 +38,20 @@ module HasAuxTable @aux_table_configs ||= T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig])) - base_table = self.table_name - aux_table_name = :"#{base_table}_#{aux_name}_aux" - - if @aux_table_configs.key?(aux_table_name) + aux_name = aux_name.to_sym + if @aux_table_configs.key?(aux_name) Kernel.raise ArgumentError, - "Auxiliary '#{aux_name}' (table '#{aux_table_name}') is already defined" + "Auxiliary '#{aux_name}' on #{self.name} (table '#{self.table_name}') already exists" end - @aux_table_configs[aux_table_name] = aux_config = - generate_aux_config(aux_table_name) + @aux_table_configs[aux_name] = aux_config = generate_aux_config(aux_name) setup_attribute_types_hook!(aux_config) - setup_columns_hook!(aux_config) setup_schema_loading_hook!(aux_config) setup_relation_extensions!(aux_config) aux_config end - # Helper method to get auxiliary table configuration - sig do - params(table_name: T.any(String, Symbol)).returns( - T.nilable(AuxTableConfig) - ) - end - def aux_table_configuration(table_name) - @aux_table_configs[table_name.to_sym] - end - private sig { params(aux_config: AuxTableConfig).void } @@ -69,7 +61,7 @@ module HasAuxTable .main_class .define_singleton_method(:attribute_types) do @aux_config_attribute_types_cache ||= {} - @aux_config_attribute_types_cache[aux_config.table_name] ||= begin + @aux_config_attribute_types_cache[aux_config.aux_table_name] ||= begin original_types = original_method.call.dup aux_types = @@ -83,29 +75,19 @@ module HasAuxTable 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) - aux_table_name = aux_config.table_name + 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 @@ -154,6 +136,7 @@ module HasAuxTable %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 @@ -172,17 +155,22 @@ module HasAuxTable ].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) - target.send(method_name, name, *args, **kwargs) + T.unsafe(target).send(method_name, name, *args, **kwargs) else - read_attribute_method.bind(self).call(name, *args, **kwargs) + 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) @@ -190,6 +178,7 @@ module HasAuxTable 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) @@ -235,20 +224,25 @@ module HasAuxTable # Generate auxiliary model class dynamically sig do params( - table_name: Symbol, + aux_name: Symbol, foreign_key: KeyType, primary_key: KeyType ).returns(AuxTableConfig) end def generate_aux_config( - table_name, + aux_name, # The column on the aux table that points to the main table 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" + # Generate class name (e.g., :car_aux => "CarAux") - aux_class_name = table_name.to_s.camelize - aux_association_name = table_name.to_s.singularize.to_sym + 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) @@ -257,12 +251,12 @@ module HasAuxTable # Get the current class for the association main_class = self - main_association_name = main_class.name.underscore.to_sym + main_association_name = T.must(main_class.name&.underscore&.to_sym) # Create the auxiliary model class aux_class = Class.new(ActiveRecord::Base) do - self.table_name = table_name.to_s + self.table_name = aux_table_name.to_s self.primary_key = foreign_key # Define the association back to the specific STI subclass @@ -294,7 +288,7 @@ module HasAuxTable Object.const_set(aux_class_name, aux_class) AuxTableConfig.new( - table_name:, + aux_table_name:, model_class: aux_class, main_class:, aux_association_name:, diff --git a/lib/has_aux_table/aux_table_config.rb b/lib/has_aux_table/aux_table_config.rb index d6013d1..52dc14e 100644 --- a/lib/has_aux_table/aux_table_config.rb +++ b/lib/has_aux_table/aux_table_config.rb @@ -5,7 +5,7 @@ module HasAuxTable class AuxTableConfig < T::Struct extend T::Sig - const :table_name, Symbol + const :aux_table_name, Symbol const :aux_association_name, Symbol const :main_association_name, Symbol const :main_class, T.class_of(ActiveRecord::Base) @@ -47,7 +47,12 @@ module HasAuxTable sig do params( - main_class: T.class_of(ActiveRecord::Base), + main_class: + T.all( + T.class_of(ActiveRecord::Base), + ActiveRecord::Associations::ClassMethods, + Module + ), method_name: Symbol ).void end diff --git a/lib/has_aux_table/migration_extensions.rb b/lib/has_aux_table/migration_extensions.rb index 5aafb7e..7dbcfc6 100644 --- a/lib/has_aux_table/migration_extensions.rb +++ b/lib/has_aux_table/migration_extensions.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true module HasAuxTable @@ -21,6 +21,10 @@ module HasAuxTable end module MigrationExtensions + extend T::Sig + extend T::Helpers + requires_ancestor { ActiveRecord::Migration } + def create_base_table(name, type: :string, **options) create_table(name, **options) do |t| t.column :type, type, null: false diff --git a/lib/has_aux_table/relation_extensions.rb b/lib/has_aux_table/relation_extensions.rb index a6463b1..13089e9 100644 --- a/lib/has_aux_table/relation_extensions.rb +++ b/lib/has_aux_table/relation_extensions.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true module HasAuxTable @@ -44,19 +44,15 @@ module HasAuxTable ) end - Util.hook_method(main_class, :find, false) do |original, arg| - original.call(arg) - end - relation_class = main_class.relation_delegate_class(ActiveRecord::Relation) 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) + T.unsafe(original).call(opts_remapped, *rest) else - original.call(opts, *rest) + T.unsafe(original).call(opts, *rest) end end diff --git a/lib/has_aux_table/util.rb b/lib/has_aux_table/util.rb index f1693c6..19d38ef 100644 --- a/lib/has_aux_table/util.rb +++ b/lib/has_aux_table/util.rb @@ -14,7 +14,7 @@ module HasAuxTable ), method_name: Symbol, is_instance_method: T::Boolean, - hook_block: T.proc.void + hook_block: T.proc.params(args: T.untyped).void ).void end def self.hook_method(target, method_name, is_instance_method, &hook_block) @@ -35,5 +35,30 @@ module HasAuxTable T.unsafe(hook_block).call(method, *args, **kwargs, &block) 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 + # 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 end end diff --git a/sorbet/config b/sorbet/config index 3a0416d..36b8918 100644 --- a/sorbet/config +++ b/sorbet/config @@ -2,3 +2,4 @@ . --ignore=/tmp/ --ignore=/vendor/bundle +--enable-experimental-requires-ancestor \ No newline at end of file diff --git a/spec/active_record/aux_table_spec.rb b/spec/active_record/aux_table_spec.rb index 2579d03..8ca8fa1 100644 --- a/spec/active_record/aux_table_spec.rb +++ b/spec/active_record/aux_table_spec.rb @@ -661,7 +661,7 @@ RSpec.describe HasAuxTable do it "can be destroyed through the association" do driver = @car.drivers.create!(name: "John Doe") - expect { driver.destroy }.to change { @car.drivers.count }.by(-1) + expect { driver.destroy }.to change { @car.reload.drivers.count }.by(-1) end end