refactor more logic into AuxTableConfig

This commit is contained in:
Dylan Knutson
2025-07-18 05:27:38 +00:00
parent d112d8b72d
commit 3a80c2b8dd
5 changed files with 298 additions and 167 deletions

View File

@@ -21,6 +21,14 @@ module HasAuxTable
VERSION = "0.1.0" VERSION = "0.1.0"
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 module ClassMethods
extend T::Sig extend T::Sig
extend T::Helpers extend T::Helpers
@@ -45,17 +53,17 @@ module HasAuxTable
"Auxiliary '#{aux_name}' on #{self.name} (table '#{self.table_name}') already exists" "Auxiliary '#{aux_name}' on #{self.name} (table '#{self.table_name}') already exists"
end end
@aux_table_configs[aux_name] = aux_config = generate_aux_config(aux_name) @aux_table_configs[aux_name] = config = generate_aux_config(aux_name)
setup_attribute_types_hook!(aux_config) setup_attribute_types_hook!(config)
setup_schema_loading_hook!(aux_config) setup_load_schema_hook!(config)
setup_initialize_hook!(aux_config) setup_initialize_hook!(config)
setup_save_hook!(aux_config) setup_save_hook!(config)
setup_reload_hook!(aux_config) setup_reload_hook!(config)
setup_attributes_hook!(aux_config) setup_attributes_hook!(config)
setup_relation_extensions!(aux_config) setup_relation_extensions!(config)
setup_attribute_getter_setter_hooks!(aux_config) setup_attribute_getter_setter_hooks!(config)
aux_config config
end end
private private
@@ -126,7 +134,7 @@ module HasAuxTable
AuxTableConfig.new( AuxTableConfig.new(
aux_table_name:, aux_table_name:,
model_class: aux_class, aux_class:,
main_class:, main_class:,
aux_association_name:, aux_association_name:,
main_association_name:, main_association_name:,
@@ -135,43 +143,49 @@ module HasAuxTable
) )
end end
sig { params(aux_config: AuxTableConfig).void } sig { params(config: AuxTableConfig).void }
def setup_attribute_types_hook!(aux_config) def setup_attribute_types_hook!(config)
original_method = aux_config.main_class.method(:attribute_types) original_method = config.main_class.method(:attribute_types)
aux_config config
.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.aux_table_name] ||= begin T.let(
original_types = original_method.call.dup {},
T.nilable(
T::Hash[Symbol, T::Hash[String, ActiveModel::Type::Value]]
)
)
aux_types = @aux_config_attribute_types_cache[config.aux_table_name] ||= begin
aux_config.model_class.attribute_types.filter do |k, _| original_types =
aux_config.is_aux_column?(k) T.let(
end original_method.call,
T::Hash[String, ActiveModel::Type::Value]
)
# move 'created_at', 'updated_at' etc to the end of the list # move 'created_at', 'updated_at' etc to the end of the list
at_types = {} timestamp_types = {}
original_types.each do |k, v| original_types.reject! do |k, v|
if k.end_with?("_at") && v.type == :datetime if k.end_with?("_at") && v.type == :datetime
at_types[k] = v timestamp_types[k] = v
original_types.delete(k) original_types.delete(k)
end end
end end
original_types.merge!(aux_types) original_types.merge!(config.aux.attribute_types)
original_types.merge!(at_types) original_types.merge!(timestamp_types)
original_types original_types
end end
end end
aux_config.main_class.attributes_for_inspect = config.main_class.attributes_for_inspect =
Util.attributes_for_inspect(aux_config) Util.attributes_for_inspect(config)
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(config: AuxTableConfig).void }
def setup_schema_loading_hook!(aux_config) def setup_load_schema_hook!(config)
# 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
@@ -180,66 +194,48 @@ module HasAuxTable
T.all(T.class_of(ActiveRecord::Base), HasAuxTable::ClassMethods) T.all(T.class_of(ActiveRecord::Base), HasAuxTable::ClassMethods)
) )
aux_config_load_schema!(load_schema_method, aux_config) aux_config_load_schema!(load_schema_method, config)
end end
self.load_schema! if self.schema_loaded? self.load_schema! if self.schema_loaded?
end end
sig { params(load_schema_method: Method, aux_config: AuxTableConfig).void } sig { params(load_schema_method: Method, config: AuxTableConfig).void }
def aux_config_load_schema!(load_schema_method, aux_config) def aux_config_load_schema!(load_schema_method, config)
# 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 config.load_aux_schema
aux_table_name = aux_config.aux_table_name aux_table_name = 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!( check_for_overlapping_columns!(
aux_table_name, aux_table_name,
main_column_names, config.main.column_names,
aux_column_names config.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 # set attributes that exist on the aux table to also exist on this table
aux_table_filtered_attributes.each do |name, attr| config.aux.default_attributes.each do |name, attr|
@default_attributes[name] = attr @default_attributes[name] = attr
end end
# Generate attribute accessors for each auxiliary column # Generate attribute accessors for each auxiliary column
aux_columns_hash.each do |column_name, column| config.aux.columns_hash.each do |column_name, column|
column_name = column_name.to_sym column_name = column_name.to_sym
if self.method_defined?(column_name.to_sym) if self.method_defined?(column_name.to_sym)
raise "invariant: method #{column_name} already defined" raise "invariant: method #{column_name} already defined"
end end
aux_config.define_aux_attribute_delegate(column_name) config.define_aux_attribute_delegate(column_name)
aux_config.define_aux_attribute_delegate(:"#{column_name}?") config.define_aux_attribute_delegate(:"#{column_name}?")
aux_config.define_aux_attribute_delegate(:"#{column_name}=") config.define_aux_attribute_delegate(:"#{column_name}=")
end end
result result
end end
sig { params(aux_config: AuxTableConfig).void } sig { params(config: AuxTableConfig).void }
def setup_attribute_getter_setter_hooks!(aux_config) def setup_attribute_getter_setter_hooks!(config)
%i[ %i[
_read_attribute _read_attribute
read_attribute read_attribute
@@ -250,8 +246,8 @@ module HasAuxTable
method = self.instance_method(method_name) method = self.instance_method(method_name)
self.define_method(method_name) do |name, *args, **kwargs, &block| self.define_method(method_name) do |name, *args, **kwargs, &block|
T.bind(self, ActiveRecord::Base) T.bind(self, ActiveRecord::Base)
if aux_config.is_aux_column?(name) if config.aux.column_names.include?(name)
target = aux_config.ensure_aux_target(self) target = config.aux_model_for(self)
T.unsafe(target).send(method_name, name, *args, **kwargs, &block) T.unsafe(target).send(method_name, name, *args, **kwargs, &block)
else else
T.unsafe(method).bind(self).call(name, *args, **kwargs, &block) T.unsafe(method).bind(self).call(name, *args, **kwargs, &block)
@@ -260,46 +256,45 @@ module HasAuxTable
end end
end end
sig { params(aux_config: AuxTableConfig).void } sig { params(config: AuxTableConfig).void }
def setup_initialize_hook!(aux_config) def setup_initialize_hook!(config)
initialize_method = self.instance_method(:initialize) initialize_method = self.instance_method(:initialize)
self.define_method(:initialize) do |args, **kwargs, &block| self.define_method(:initialize) do |args, **kwargs, &block|
T.bind(self, ActiveRecord::Base) T.bind(self, ActiveRecord::Base)
aux_args, main_args = main_args, aux_args = config.aux.partition_by_columns(args)
args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h)
initialize_method.bind(self).call(main_args, **kwargs, &block) initialize_method.bind(self).call(main_args, **kwargs, &block)
aux_config.assign_aux_attributes(self, aux_args) config.aux_model_for(self).assign_attributes(aux_args)
end end
end end
sig { params(aux_config: AuxTableConfig).void } sig { params(config: AuxTableConfig).void }
def setup_save_hook!(aux_config) def setup_save_hook!(config)
%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, &block|
T.bind(self, ActiveRecord::Base) T.bind(self, ActiveRecord::Base)
result = save_method.bind(self).call(*args, **kwargs) result = save_method.bind(self).call(*args, **kwargs, &block)
result &&= result &&=
self self
.association(aux_config.aux_association_name) .association(config.aux_association_name)
.target .target
.send(method_name, *args, **kwargs) .send(method_name, *args, **kwargs, &block)
result result
end end
end end
end end
sig { params(aux_config: AuxTableConfig).void } sig { params(config: AuxTableConfig).void }
def setup_reload_hook!(aux_config) def setup_reload_hook!(config)
self.define_method(:reload) do |*args| self.define_method(:reload) do |*args|
T.bind(self, ActiveRecord::Base) T.bind(self, ActiveRecord::Base)
aux_model = aux_config.ensure_aux_target(self) aux_model = config.aux_model_for(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)
aux_model.instance_variable_set( aux_model.instance_variable_set(
:@attributes, :@attributes,
fresh_model fresh_model
.association(aux_config.aux_association_name) .association(config.aux_association_name)
.target .target
.instance_variable_get(:@attributes) .instance_variable_get(:@attributes)
) )
@@ -307,13 +302,14 @@ module HasAuxTable
end end
end end
sig { params(aux_config: AuxTableConfig).void } sig { params(config: AuxTableConfig).void }
def setup_attributes_hook!(aux_config) def setup_attributes_hook!(config)
attributes_method = self.instance_method(:attributes) attributes_method = self.instance_method(:attributes)
self.define_method(:attributes) do |*args| self.define_method(:attributes) do |*args|
T.bind(self, ActiveRecord::Base) T.bind(self, ActiveRecord::Base)
ret = attributes_method.bind(self).call(*args) ret = attributes_method.bind(self).call(*args)
ret.merge!(aux_config.aux_attributes(self)) target = config.aux_model_for(self)
ret.merge!(config.aux.attributes_on(target))
ret ret
end end
end end

View File

@@ -2,6 +2,114 @@
# frozen_string_literal: true # frozen_string_literal: true
module HasAuxTable module HasAuxTable
class ModelClassHelper < T::Struct
extend T::Sig
const :klass, T.class_of(ActiveRecord::Base)
const :rejected_column_names, T::Set[String]
sig { params(name: T.any(String, Symbol)).returns(T::Boolean) }
def is_column?(name)
column_names.include?(name.to_s)
end
sig { returns(T::Array[String]) }
def column_names
@column_names ||=
T.let(
begin
klass
.column_names
.reject { |col| rejected_column_names.include?(col.to_s) }
.map(&:to_s)
end,
T.nilable(T::Array[String])
)
end
sig { returns(T::Hash[String, ActiveRecord::ConnectionAdapters::Column]) }
def columns_hash
@columns_hash ||=
T.let(
slice_by_columns(klass.columns_hash),
T.nilable(T::Hash[String, ActiveRecord::ConnectionAdapters::Column])
)
end
sig { returns(T::Hash[String, ActiveModel::Type]) }
def attribute_types
@attribute_types ||=
T.let(
slice_by_columns(klass.attribute_types),
T.nilable(T::Hash[String, ActiveModel::Type])
)
end
sig { returns(T::Hash[String, ActiveModel::Attribute]) }
def default_attributes
@default_attributes ||=
T.let(
begin
da = klass._default_attributes
da.keys.map { |k, v| [k, da[k]] }.to_h.slice(*self.column_names)
end,
T.nilable(T::Hash[String, ActiveModel::Attribute])
)
end
sig do
params(instance: ActiveRecord::Base).returns(T::Hash[String, T.untyped])
end
def attributes_on(instance)
Util.ensure_is_instance_of!(instance, self.klass)
unless instance.class <= self.klass
raise("#{instance.class.name} not a #{self.klass.name}")
end
slice_by_columns(instance.attributes)
end
sig do
type_parameters(:K, :T)
.params(
hash:
T::Hash[
T.all(T.type_parameter(:K), T.any(String, Symbol)),
T.type_parameter(:T)
]
)
.returns(
[
T::Hash[
T.all(T.type_parameter(:K), T.any(String, Symbol)),
T.type_parameter(:T)
],
T::Hash[
T.all(T.type_parameter(:K), T.any(String, Symbol)),
T.type_parameter(:T)
]
]
)
end
def partition_by_columns(hash)
a, b =
hash
.partition { |k, _| !self.column_names.include?(k.to_s) }
.map(&:to_h)
[T.must(a), T.must(b)]
end
private
sig do
type_parameters(:T)
.params(hash: T::Hash[String, T.type_parameter(:T)])
.returns(T::Hash[String, T.type_parameter(:T)])
end
def slice_by_columns(hash)
T.unsafe(hash).slice(*self.column_names)
end
end
class AuxTableConfig < T::Struct class AuxTableConfig < T::Struct
extend T::Sig extend T::Sig
@@ -9,21 +117,48 @@ module HasAuxTable
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)
const :model_class, T.class_of(ActiveRecord::Base) const :aux_class, T.class_of(ActiveRecord::Base)
const :foreign_key, KeyType const :foreign_key, KeyType
const :primary_key, KeyType const :primary_key, KeyType
sig { void } sig { void }
def load_aux_schema def load_aux_schema
model_class.load_schema aux_class.load_schema
end end
sig { params(main_model: ActiveRecord::Base).returns(ActiveRecord::Base) } sig { returns(ModelClassHelper) }
def ensure_aux_target(main_model) def aux
aux_association = main_model.association(self.aux_association_name) @aux ||=
T.let(
ModelClassHelper.new(
klass: self.aux_class,
rejected_column_names: self.aux_rejected_column_names.to_set
),
T.nilable(ModelClassHelper)
)
end
sig { returns(ModelClassHelper) }
def main
@main ||=
T.let(
ModelClassHelper.new(
klass: self.main_class,
rejected_column_names: Set.new
),
T.nilable(ModelClassHelper)
)
end
sig do
params(main_instance: ActiveRecord::Base).returns(ActiveRecord::Base)
end
def aux_model_for(main_instance)
Util.ensure_is_instance_of!(main_instance, main_class)
aux_association = main_instance.association(self.aux_association_name)
aux_association.target ||= aux_association.target ||=
( (
if main_model.persisted? if main_instance.persisted?
aux_association.load_target || aux_association.build aux_association.load_target || aux_association.build
else else
aux_association.build aux_association.build
@@ -43,25 +178,20 @@ module HasAuxTable
).returns(Arel::Nodes::Node) ).returns(Arel::Nodes::Node)
end end
def aux_bind_attribute(name, value, &block) def aux_bind_attribute(name, value, &block)
arel_attr = model_class.arel_table[name] arel_attr = aux_class.arel_table[name]
aux_bind = aux_bind =
model_class.predicate_builder.build_bind_attribute( aux_class.predicate_builder.build_bind_attribute(arel_attr.name, value)
arel_attr.name,
value
)
block.call(arel_attr, aux_bind) block.call(arel_attr, aux_bind)
end end
sig { params(method_name: Symbol).void } sig { params(method_name: Symbol).void }
def define_aux_attribute_delegate(method_name) def define_aux_attribute_delegate(method_name)
aux_config = self config = self
aux_config main_class.define_method(method_name) do |*args, **kwargs, &block|
.main_class T.bind(self, ActiveRecord::Base)
.define_method(method_name) do |*args, **kwargs| aux_model = config.aux_model_for(self)
T.bind(self, ActiveRecord::Base) T.unsafe(aux_model).public_send(method_name, *args, **kwargs, &block)
aux_model = aux_config.ensure_aux_target(self) end
T.unsafe(aux_model).public_send(method_name, *args, **kwargs)
end
end end
sig do sig do
@@ -72,7 +202,7 @@ module HasAuxTable
end end
def apply_split_conditions!(relation, conditions) def apply_split_conditions!(relation, conditions)
main_conditions, aux_conditions = main_conditions, aux_conditions =
self.partition_by_aux_columns(conditions) self.aux.partition_by_columns(conditions)
relation = relation.where(main_conditions) if main_conditions.any? relation = relation.where(main_conditions) if main_conditions.any?
if aux_conditions.any? if aux_conditions.any?
relation = relation.where(aux_association_name => aux_conditions) relation = relation.where(aux_association_name => aux_conditions)
@@ -86,66 +216,22 @@ module HasAuxTable
) )
end end
def remap_conditions(conditions) def remap_conditions(conditions)
main, aux = partition_by_aux_columns(conditions) main_conds, aux_conds = aux.partition_by_columns(conditions)
main.merge!(aux_association_name => aux) if aux.any? main_conds.merge!(aux_association_name => aux_conds) if aux_conds.any?
main main_conds
end
sig do
params(
main_model: ActiveRecord::Base,
aux_args: T::Hash[Symbol, T.untyped]
).void
end
def assign_aux_attributes(main_model, aux_args)
aux_model = self.ensure_aux_target(main_model)
aux_model.assign_attributes(aux_args)
end
sig do
params(main_model: ActiveRecord::Base).returns(T::Hash[Symbol, T.untyped])
end
def aux_attributes(main_model)
aux_model = self.ensure_aux_target(main_model)
aux_model.attributes.slice(*self.aux_column_names)
end
sig { returns(T::Array[String]) }
def aux_column_names
@aux_column_names ||=
T.let(
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,
T.nilable(T::Array[String])
)
end
sig { params(name: T.any(Symbol, String)).returns(T::Boolean) }
def is_aux_column?(name)
aux_column_names.include?(name.to_s)
end end
private private
sig do sig { returns(T::Set[String]) }
params(hash: T::Hash[String, T.untyped]).returns( def aux_rejected_column_names
[T::Hash[String, T.untyped], T::Hash[String, T.untyped]] @aux_rejected_column_names ||=
) T.let(
end [foreign_key, primary_key, "created_at", "updated_at"].flatten
def partition_by_aux_columns(hash) .map(&:to_s)
a, b, _ = hash.partition { |k, _| !self.is_aux_column?(k) }.map(&:to_h) .to_set,
[T.must(a), T.must(b)] T.nilable(T::Set[String])
)
end end
end end
end end

View File

@@ -73,7 +73,7 @@ module HasAuxTable
:bind_attribute, :bind_attribute,
true true
) do |original, name, value, &block| ) do |original, name, value, &block|
if aux_config.is_aux_column?(name) if aux_config.aux.is_column?(name)
aux_config.aux_bind_attribute(name, value, &block) aux_config.aux_bind_attribute(name, value, &block)
else else
original.call(name, value, &block) original.call(name, value, &block)

View File

@@ -51,5 +51,19 @@ module HasAuxTable
main_class_attributes main_class_attributes
end end
sig do
type_parameters(:T)
.params(
instance: T.all(T.type_parameter(:T), Object),
klass: T::Class[T.type_parameter(:T)]
)
.void
end
def self.ensure_is_instance_of!(instance, klass)
unless instance.class <= klass
Kernel.raise("#{instance.class.name} not a #{klass.name}")
end
end
end end
end end

View File

@@ -28,8 +28,14 @@ RSpec.describe HasAuxTable do
before(:all) do before(:all) do
# Set up the database schema for testing # Set up the database schema for testing
ActiveRecord::Schema.define do ActiveRecord::Schema.define do
create_table :vehicle_lots do |t|
t.string :name
t.timestamps
end
create_base_table :vehicles do |t| create_base_table :vehicles do |t|
t.string :name t.string :name
t.references :vehicle_lot, foreign_key: { to_table: :vehicle_lots }
t.timestamps t.timestamps
t.create_aux :car do |t| t.create_aux :car do |t|
@@ -92,6 +98,11 @@ RSpec.describe HasAuxTable do
class Vehicle < ActiveRecord::Base class Vehicle < ActiveRecord::Base
include HasAuxTable include HasAuxTable
belongs_to :vehicle_lot
end
class VehicleLot < ActiveRecord::Base
has_many :vehicles
end end
class Car < Vehicle class Car < Vehicle
@@ -191,16 +202,40 @@ RSpec.describe HasAuxTable do
car = car =
Car.create!(name: "Honda Civic", fuel_type: "gasoline", engine_size: 2.0) Car.create!(name: "Honda Civic", fuel_type: "gasoline", engine_size: 2.0)
expect(car.attributes).to match( expect(car.attributes).to match(
"type" => "Car", hash_including(
"id" => car.id, "type" => "Car",
"name" => "Honda Civic", "id" => car.id,
"fuel_type" => "gasoline", "name" => "Honda Civic",
"engine_size" => be_within(0.001).of(2.0), "fuel_type" => "gasoline",
"created_at" => be_within(0.001).of(car.created_at), "engine_size" => be_within(0.001).of(2.0),
"updated_at" => be_within(0.001).of(car.updated_at) "created_at" => be_within(0.001).of(car.created_at),
"updated_at" => be_within(0.001).of(car.updated_at)
)
) )
end end
it "can be created as the base class" do
vehicle = Vehicle.create(type: "Vehicle", name: "big tractor")
expect(vehicle.attributes).to match(
hash_including(
"type" => "Vehicle",
"id" => vehicle.id,
"name" => "big tractor",
"created_at" => be_within(0.001).of(vehicle.created_at),
"updated_at" => be_within(0.001).of(vehicle.updated_at)
)
)
end
it "can be created through an association" do
lot = VehicleLot.create(name: "lot1")
lot.vehicles.create { |b| b.name = "vehicle1" }
lot.save!
lot.reload
expect(lot.vehicles.count).to eq(1)
expect(lot.vehicles.first.name).to eq("vehicle1")
end
describe "database integration" do describe "database integration" do
it "provides automatic attribute accessors for auxiliary table columns" do it "provides automatic attribute accessors for auxiliary table columns" do
vehicle = Car.create!(name: "Honda Civic") vehicle = Car.create!(name: "Honda Civic")