more typing

This commit is contained in:
Dylan Knutson
2025-07-16 18:23:35 +00:00
parent 331af0683e
commit fe0f7b9bbe
8 changed files with 84 additions and 59 deletions

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
active-record-aux-table (0.1.0) has_aux_table (0.1.0)
activerecord (>= 7.0) activerecord (>= 7.0)
activesupport (>= 7.0) activesupport (>= 7.0)
sorbet-runtime (~> 0.5) sorbet-runtime (~> 0.5)
@@ -141,9 +141,9 @@ PLATFORMS
aarch64-linux aarch64-linux
DEPENDENCIES DEPENDENCIES
active-record-aux-table!
bundler-audit bundler-audit
debug debug
has_aux_table!
lefthook lefthook
pry pry
rake (~> 13.0) rake (~> 13.0)

View File

@@ -1,4 +1,4 @@
# typed: false # typed: true
# frozen_string_literal: true # frozen_string_literal: true
require "sorbet-runtime" require "sorbet-runtime"
@@ -24,6 +24,12 @@ module HasAuxTable
module ClassMethods module ClassMethods
extend T::Sig extend T::Sig
extend T::Helpers 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 include RelationExtensions
# Main DSL method for defining auxiliary tables # Main DSL method for defining auxiliary tables
@@ -32,34 +38,20 @@ module HasAuxTable
@aux_table_configs ||= @aux_table_configs ||=
T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig])) T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig]))
base_table = self.table_name aux_name = aux_name.to_sym
aux_table_name = :"#{base_table}_#{aux_name}_aux" if @aux_table_configs.key?(aux_name)
if @aux_table_configs.key?(aux_table_name)
Kernel.raise ArgumentError, 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 end
@aux_table_configs[aux_table_name] = aux_config = @aux_table_configs[aux_name] = aux_config = generate_aux_config(aux_name)
generate_aux_config(aux_table_name)
setup_attribute_types_hook!(aux_config) setup_attribute_types_hook!(aux_config)
setup_columns_hook!(aux_config)
setup_schema_loading_hook!(aux_config) setup_schema_loading_hook!(aux_config)
setup_relation_extensions!(aux_config) setup_relation_extensions!(aux_config)
aux_config aux_config
end 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 private
sig { params(aux_config: AuxTableConfig).void } sig { params(aux_config: AuxTableConfig).void }
@@ -69,7 +61,7 @@ module HasAuxTable
.main_class .main_class
.define_singleton_method(:attribute_types) do .define_singleton_method(:attribute_types) do
@aux_config_attribute_types_cache ||= {} @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 original_types = original_method.call.dup
aux_types = aux_types =
@@ -83,29 +75,19 @@ module HasAuxTable
end 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 # Hook into schema loading to generate attribute accessors when schema is loaded
sig { params(aux_config: AuxTableConfig).void } sig { params(aux_config: AuxTableConfig).void }
def setup_schema_loading_hook!(aux_config) 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 # Override load_schema to also generate auxiliary attribute accessors when schema is loaded
load_schema_method = self.method(:load_schema!) load_schema_method = self.method(:load_schema!)
self.define_singleton_method(:load_schema!) do 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 # first, load the main and aux table schemas like normal
result = load_schema_method.call result = load_schema_method.call
aux_config.load_aux_schema aux_config.load_aux_schema
@@ -154,6 +136,7 @@ module HasAuxTable
%i[save save!].each do |method_name| %i[save save!].each do |method_name|
save_method = self.instance_method(method_name) save_method = self.instance_method(method_name)
self.define_method(method_name) do |*args, **kwargs| self.define_method(method_name) do |*args, **kwargs|
T.bind(self, ActiveRecord::Base)
result = save_method.bind(self).call(*args, **kwargs) result = save_method.bind(self).call(*args, **kwargs)
result &&= result &&=
self self
@@ -172,17 +155,22 @@ module HasAuxTable
].each do |method_name| ].each do |method_name|
read_attribute_method = self.instance_method(method_name) read_attribute_method = self.instance_method(method_name)
self.define_method(method_name) do |name, *args, **kwargs| self.define_method(method_name) do |name, *args, **kwargs|
T.bind(self, ActiveRecord::Base)
if aux_config.is_aux_column?(name) if aux_config.is_aux_column?(name)
target = aux_config.ensure_aux_target(self) target = aux_config.ensure_aux_target(self)
target.send(method_name, name, *args, **kwargs) T.unsafe(target).send(method_name, name, *args, **kwargs)
else else
read_attribute_method.bind(self).call(name, *args, **kwargs) T
.unsafe(read_attribute_method)
.bind(self)
.call(name, *args, **kwargs)
end end
end end
end end
initialize_method = self.instance_method(:initialize) initialize_method = self.instance_method(:initialize)
self.define_method(:initialize) do |args| self.define_method(:initialize) do |args|
T.bind(self, ActiveRecord::Base)
aux_args, main_args = aux_args, main_args =
args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h) args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h)
initialize_method.bind(self).call(main_args) initialize_method.bind(self).call(main_args)
@@ -190,6 +178,7 @@ module HasAuxTable
end end
self.define_method(:reload) do |*args| self.define_method(:reload) do |*args|
T.bind(self, ActiveRecord::Base)
aux_model = aux_config.ensure_aux_target(self) aux_model = aux_config.ensure_aux_target(self)
fresh_model = self.class.find(id) fresh_model = self.class.find(id)
@attributes = fresh_model.instance_variable_get(:@attributes) @attributes = fresh_model.instance_variable_get(:@attributes)
@@ -235,20 +224,25 @@ module HasAuxTable
# Generate auxiliary model class dynamically # Generate auxiliary model class dynamically
sig do sig do
params( params(
table_name: Symbol, aux_name: Symbol,
foreign_key: KeyType, foreign_key: KeyType,
primary_key: KeyType primary_key: KeyType
).returns(AuxTableConfig) ).returns(AuxTableConfig)
end end
def generate_aux_config( def generate_aux_config(
table_name, aux_name,
# The column on the aux table that points to the main table # The column on the aux table that points to the main table
foreign_key: :base_table_id, foreign_key: :base_table_id,
primary_key: self.primary_key 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") # Generate class name (e.g., :car_aux => "CarAux")
aux_class_name = table_name.to_s.camelize aux_class_name = aux_table_name.to_s.camelize
aux_association_name = table_name.to_s.singularize.to_sym aux_association_name = aux_table_name.to_s.singularize.to_sym
# Ensure the class name doesn't conflict with existing constants # Ensure the class name doesn't conflict with existing constants
if Object.const_defined?(aux_class_name) if Object.const_defined?(aux_class_name)
@@ -257,12 +251,12 @@ module HasAuxTable
# Get the current class for the association # Get the current class for the association
main_class = self 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 # Create the auxiliary model class
aux_class = aux_class =
Class.new(ActiveRecord::Base) do 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 self.primary_key = foreign_key
# Define the association back to the specific STI subclass # Define the association back to the specific STI subclass
@@ -294,7 +288,7 @@ module HasAuxTable
Object.const_set(aux_class_name, aux_class) Object.const_set(aux_class_name, aux_class)
AuxTableConfig.new( AuxTableConfig.new(
table_name:, aux_table_name:,
model_class: aux_class, model_class: aux_class,
main_class:, main_class:,
aux_association_name:, aux_association_name:,

View File

@@ -5,7 +5,7 @@ module HasAuxTable
class AuxTableConfig < T::Struct class AuxTableConfig < T::Struct
extend T::Sig extend T::Sig
const :table_name, Symbol const :aux_table_name, Symbol
const :aux_association_name, Symbol const :aux_association_name, Symbol
const :main_association_name, Symbol const :main_association_name, Symbol
const :main_class, T.class_of(ActiveRecord::Base) const :main_class, T.class_of(ActiveRecord::Base)
@@ -47,7 +47,12 @@ module HasAuxTable
sig do sig do
params( params(
main_class: T.class_of(ActiveRecord::Base), main_class:
T.all(
T.class_of(ActiveRecord::Base),
ActiveRecord::Associations::ClassMethods,
Module
),
method_name: Symbol method_name: Symbol
).void ).void
end end

View File

@@ -1,4 +1,4 @@
# typed: false # typed: true
# frozen_string_literal: true # frozen_string_literal: true
module HasAuxTable module HasAuxTable
@@ -21,6 +21,10 @@ module HasAuxTable
end end
module MigrationExtensions module MigrationExtensions
extend T::Sig
extend T::Helpers
requires_ancestor { ActiveRecord::Migration }
def create_base_table(name, type: :string, **options) def create_base_table(name, type: :string, **options)
create_table(name, **options) do |t| create_table(name, **options) do |t|
t.column :type, type, null: false t.column :type, type, null: false

View File

@@ -1,4 +1,4 @@
# typed: false # typed: true
# frozen_string_literal: true # frozen_string_literal: true
module HasAuxTable module HasAuxTable
@@ -44,19 +44,15 @@ module HasAuxTable
) )
end end
Util.hook_method(main_class, :find, false) do |original, arg|
original.call(arg)
end
relation_class = relation_class =
main_class.relation_delegate_class(ActiveRecord::Relation) main_class.relation_delegate_class(ActiveRecord::Relation)
Util.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) if opts.is_a?(Hash)
opts_remapped = aux_config.remap_conditions(opts) opts_remapped = aux_config.remap_conditions(opts)
original.call(opts_remapped, *rest) T.unsafe(original).call(opts_remapped, *rest)
else else
original.call(opts, *rest) T.unsafe(original).call(opts, *rest)
end end
end end

View File

@@ -14,7 +14,7 @@ module HasAuxTable
), ),
method_name: Symbol, method_name: Symbol,
is_instance_method: T::Boolean, is_instance_method: T::Boolean,
hook_block: T.proc.void hook_block: T.proc.params(args: T.untyped).void
).void ).void
end end
def self.hook_method(target, method_name, is_instance_method, &hook_block) 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) T.unsafe(hook_block).call(method, *args, **kwargs, &block)
end end
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
end end

View File

@@ -2,3 +2,4 @@
. .
--ignore=/tmp/ --ignore=/tmp/
--ignore=/vendor/bundle --ignore=/vendor/bundle
--enable-experimental-requires-ancestor

View File

@@ -661,7 +661,7 @@ RSpec.describe HasAuxTable do
it "can be destroyed through the association" do it "can be destroyed through the association" do
driver = @car.drivers.create!(name: "John Doe") 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
end end