Files
has_aux_table/lib/has_aux_table.rb
2025-07-15 03:50:34 +00:00

350 lines
11 KiB
Ruby

# typed: false
# frozen_string_literal: true
require "sorbet-runtime"
require "active_record"
require "active_record/base"
require "active_support"
require "active_support/concern"
require "active_model/attribute_set"
require_relative "aux_table/auto_join_queries"
require_relative "aux_table/migration_extensions"
module HasAuxTable
extend T::Sig
extend T::Helpers
extend ActiveSupport::Concern
VERSION = "0.1.0"
# AuxTable class to store auxiliary table definition
class AuxTableConfig < T::Struct
extend T::Sig
const :table_name, Symbol
const :aux_association_name, Symbol
const :main_association_name, Symbol
const :model_class, T.class_of(ActiveRecord::Base)
const :foreign_key, T.any(Symbol, T::Array[Symbol])
const :primary_key, T.any(Symbol, T::Array[Symbol])
sig { void }
def load_aux_schema
model_class.load_schema
end
sig { params(main_model: ActiveRecord::Base).returns(ActiveRecord::Base) }
def ensure_aux_target(main_model)
aux_association = main_model.association(self.aux_association_name)
aux_association.target ||= aux_association.build
end
sig do
params(name: Symbol, value: T.untyped, block: T.proc.void).returns(
Arel::Nodes::Node
)
end
def aux_bind_attribute(name, value, &block)
arel_attr = model_class.arel_table[name]
aux_bind =
model_class.predicate_builder.build_bind_attribute(
arel_attr.name,
value
)
block.call(arel_attr, aux_bind)
end
sig do
params(
main_class: T.class_of(ActiveRecord::Base),
method_name: Symbol
).void
end
def define_aux_attribute_delegate(main_class, method_name)
aux_config = self
main_class.define_method(method_name) do |*args, **kwargs|
aux_model = aux_config.ensure_aux_target(self)
aux_model.public_send(method_name, *args, **kwargs)
end
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 { returns(T::Array[Symbol]) }
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)
end
sig { params(name: T.any(Symbol, String)).returns(T::Boolean) }
def is_aux_column?(name)
aux_column_names.include?(name.to_sym)
end
end
module ClassMethods
extend T::Sig
include QueryExtensions
# Main DSL method for defining auxiliary tables
sig { params(aux_name: T.any(String, Symbol)).returns(AuxTableConfig) }
def aux_table(aux_name)
@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)
Kernel.raise ArgumentError,
"Auxiliary '#{aux_name}' (table '#{aux_table_name}') is already defined"
end
@aux_table_configs[aux_table_name] = aux_config =
generate_aux_config(aux_table_name)
setup_schema_loading_hook!(aux_table_name)
setup_query_extensions!(self, aux_config, with_bind_attribute: false)
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
# Hook into schema loading to generate attribute accessors when schema is loaded
sig { params(aux_table_name: Symbol).void }
def setup_schema_loading_hook!(aux_table_name)
aux_config =
@aux_table_configs[aux_table_name] ||
raise("no aux_config for #{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
# first, load the main and aux table schemas like normal
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
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
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|
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].each do |method_name|
read_attribute_method = self.instance_method(method_name)
self.define_method(method_name) do |name, *args, **kwargs|
if aux_config.is_aux_column?(name)
target = aux_config.ensure_aux_target(self)
target.send(method_name, name, *args, **kwargs)
else
read_attribute_method.bind(self).call(name, *args, **kwargs)
end
end
end
initialize_method = self.instance_method(:initialize)
self.define_method(:initialize) do |args|
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
# reload_method = self.instance_method(:reload)
self.define_method(:reload) do |*args|
result = nil
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 { params(table_name: Symbol).returns(AuxTableConfig) }
def generate_aux_config(table_name)
# 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
# Ensure the class name doesn't conflict with existing constants
if Object.const_defined?(aux_class_name)
Kernel.raise ArgumentError, "Class #{aux_class_name} already exists"
end
# 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
# Get the current class for the association
main_class = self
main_association_name = main_class.name.underscore.to_sym
primary_key = :id
# Create the auxiliary model class
aux_class =
Class.new(ActiveRecord::Base) do
# Set the table name
self.table_name = table_name.to_s
self.primary_key = "base_table_id"
# Define the association back to the specific STI subclass
# Foreign key points to base STI table (e.g., vehicle_id)
# But association is to the specific subclass (e.g., Car)
self.belongs_to(
main_association_name,
class_name: main_class.name,
foreign_key:,
primary_key:,
inverse_of: aux_association_name
)
end
# set up has_one association to the auxiliary table
self.has_one(
aux_association_name,
class_name: aux_class_name,
foreign_key:,
primary_key:,
inverse_of: main_association_name,
dependent: :destroy
# autosave: true
)
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)
AuxTableConfig.new(
table_name:,
model_class: aux_class,
aux_association_name:,
main_association_name:,
foreign_key:,
primary_key:
)
end
end
mixes_in_class_methods(ClassMethods)
end