267 lines
8.8 KiB
Ruby
267 lines
8.8 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 "has_aux_table/aux_table_config"
|
|
require_relative "has_aux_table/query_extensions"
|
|
require_relative "has_aux_table/migration_extensions"
|
|
|
|
module HasAuxTable
|
|
extend T::Sig
|
|
extend T::Helpers
|
|
extend ActiveSupport::Concern
|
|
|
|
VERSION = "0.1.0"
|
|
|
|
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
|
|
|
|
self.define_method(:reload) do |*args|
|
|
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
|
|
)
|
|
|
|
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)
|
|
|
|
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
|