ActiveRecord::AuxTable -> HasAuxTable
This commit is contained in:
@@ -1,326 +0,0 @@
|
||||
# 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 ActiveRecord
|
||||
module AuxTable
|
||||
extend T::Sig
|
||||
extend T::Helpers
|
||||
|
||||
VERSION = "0.1.0"
|
||||
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# 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])
|
||||
|
||||
def load_aux_schema
|
||||
model_class.load_schema
|
||||
end
|
||||
|
||||
def ensure_aux_target(main_model)
|
||||
aux_association = main_model.association(self.aux_association_name)
|
||||
aux_association.target ||= aux_association.build
|
||||
end
|
||||
|
||||
def define_aux_attribute_delegate(main_model, method_name)
|
||||
aux_config = self
|
||||
main_model.define_method(method_name) do |*args|
|
||||
aux_model = aux_config.ensure_aux_target(self)
|
||||
aux_model.public_send(method_name, *args)
|
||||
end
|
||||
end
|
||||
|
||||
def assign_aux_attributes(main_model, aux_args)
|
||||
aux_model = self.ensure_aux_target(main_model)
|
||||
aux_model.assign_attributes(aux_args)
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
# Initialize aux table configurations for this class
|
||||
@aux_table_configurations =
|
||||
T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig]))
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
extend T::Sig
|
||||
include AutoJoinQueries
|
||||
|
||||
# Accessor methods for aux table configurations
|
||||
sig { returns(T::Hash[Symbol, AuxTableConfig]) }
|
||||
def aux_table_configurations
|
||||
@aux_table_configurations ||=
|
||||
T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig]))
|
||||
end
|
||||
|
||||
sig { params(value: T::Hash[Symbol, AuxTableConfig]).void }
|
||||
def aux_table_configurations=(value)
|
||||
@aux_table_configurations = value
|
||||
end
|
||||
|
||||
# Main DSL method for defining auxiliary tables
|
||||
sig { params(aux_name: T.any(String, Symbol)).returns(AuxTableConfig) }
|
||||
def aux_table(aux_name)
|
||||
base_table = self.table_name
|
||||
aux_table_name = :"#{base_table}_#{aux_name}_aux"
|
||||
|
||||
if aux_table_configurations.key?(aux_table_name)
|
||||
Kernel.raise ArgumentError,
|
||||
"Auxiliary '#{aux_name}' (table '#{aux_table_name}') is already defined"
|
||||
end
|
||||
|
||||
aux_table_configurations[aux_table_name] = config =
|
||||
generate_aux_model_class(aux_table_name)
|
||||
setup_schema_loading_hook(aux_table_name)
|
||||
setup_auto_join_queries(config)
|
||||
|
||||
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_configurations[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_configurations[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|
|
||||
if self.method_defined?(column_name)
|
||||
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].each do |method_name|
|
||||
# override _read_attribute to delegate auxiliary columns to the auxiliary table
|
||||
read_attribute_method = self.instance_method(method_name)
|
||||
self.define_method(method_name) do |name|
|
||||
if aux_columns_hash.include?(name.to_s)
|
||||
target = aux_config.ensure_aux_target(self)
|
||||
target.send(method_name, name)
|
||||
else
|
||||
read_attribute_method.bind(self).call(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
initialize_method = self.instance_method(:initialize)
|
||||
self.define_method(:initialize) do |args|
|
||||
aux_args, main_args =
|
||||
args
|
||||
.partition { |k, _| aux_columns_hash.key?(k.to_s) }
|
||||
.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)
|
||||
)
|
||||
# ActiveRecord::Base.transaction do
|
||||
# aux_model = aux_config.ensure_aux_target(self)
|
||||
# result = reload_method.bind(self).call(*args)
|
||||
# self.send(:"#{aux_config.aux_association_name}=", aux_model)
|
||||
# end
|
||||
# fresh_model =
|
||||
# result
|
||||
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_model_class(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
|
||||
# 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
|
||||
end
|
||||
@@ -1,128 +0,0 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ActiveRecord
|
||||
module AuxTable
|
||||
module AutoJoinQueries
|
||||
def setup_auto_join_queries(aux_config)
|
||||
ActiveRecord::AuxTable::AutoJoinQueries.setup_query_extensions!(
|
||||
self,
|
||||
aux_config,
|
||||
with_bind_attribute: false
|
||||
)
|
||||
end
|
||||
|
||||
# Get all aux column names for this model
|
||||
def get_aux_column_names(aux_table_name)
|
||||
config = self.aux_table_configurations[aux_table_name]
|
||||
return [] unless config&.model_class
|
||||
|
||||
config.model_class.column_names.reject do |col|
|
||||
%w[base_table_id created_at updated_at].include?(col)
|
||||
end
|
||||
end
|
||||
|
||||
# Split conditions into main table and aux table conditions
|
||||
def split_conditions(conditions, association_name)
|
||||
aux_columns = self.get_aux_column_names(association_name)
|
||||
main_conditions = {}
|
||||
aux_conditions = {}
|
||||
|
||||
conditions.each do |key, value|
|
||||
if aux_columns.include?(key.to_s)
|
||||
aux_conditions[key] = value
|
||||
else
|
||||
main_conditions[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
[main_conditions, aux_conditions]
|
||||
end
|
||||
|
||||
def self.setup_query_extensions!(
|
||||
on,
|
||||
aux_config,
|
||||
with_bind_attribute: true
|
||||
)
|
||||
association_name = aux_config.aux_association_name
|
||||
on.define_singleton_method(:where) do |*args|
|
||||
if args.first.is_a?(Hash)
|
||||
relation = self.eager_load(association_name)
|
||||
self.apply_split_conditions!(relation, args)
|
||||
ActiveRecord::AuxTable::AutoJoinQueries.setup_query_extensions!(
|
||||
relation,
|
||||
aux_config
|
||||
)
|
||||
relation
|
||||
else
|
||||
super(*args)
|
||||
end
|
||||
end
|
||||
|
||||
unscoped_method = on.method(:unscoped)
|
||||
on.define_singleton_method(:unscoped) do
|
||||
relation = unscoped_method.call
|
||||
ActiveRecord::AuxTable::AutoJoinQueries.setup_query_extensions!(
|
||||
relation,
|
||||
aux_config
|
||||
)
|
||||
relation
|
||||
end
|
||||
|
||||
if with_bind_attribute
|
||||
bind_attribute_method = on.method(:bind_attribute)
|
||||
on.define_singleton_method(:bind_attribute) do |name, value, &block|
|
||||
aux_column_names = self.get_aux_column_names(association_name)
|
||||
if aux_column_names.include?(name.to_s)
|
||||
attr = aux_config.model_class.arel_table[name]
|
||||
bind =
|
||||
aux_config.model_class.predicate_builder.build_bind_attribute(
|
||||
attr.name,
|
||||
value
|
||||
)
|
||||
|
||||
block.call(attr, bind)
|
||||
else
|
||||
bind_attribute_method.call(name, value, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on.define_singleton_method(:find_by) do |*args|
|
||||
relation = self.eager_load(association_name)
|
||||
self.apply_split_conditions!(relation, args)
|
||||
relation.first
|
||||
end
|
||||
|
||||
on.define_singleton_method(:apply_split_conditions!) do |relation, args|
|
||||
conditions = args.first
|
||||
main_conditions, aux_conditions =
|
||||
self.split_conditions(conditions, association_name)
|
||||
relation.where!(main_conditions) if main_conditions.any?
|
||||
if aux_conditions.any?
|
||||
relation.where!(association_name => aux_conditions)
|
||||
end
|
||||
end
|
||||
|
||||
on.define_singleton_method(:find) do |*args|
|
||||
self.eager_load(association_name).find(*args)
|
||||
end
|
||||
|
||||
on.define_singleton_method(:exists?) do |*args|
|
||||
conditions = args.first || {}
|
||||
main_conditions, aux_conditions =
|
||||
self.split_conditions(conditions, association_name)
|
||||
puts "checking with conditions: #{main_conditions} / #{aux_conditions}"
|
||||
|
||||
relation = self.select("1").joins(association_name)
|
||||
relation.where!(main_conditions) if main_conditions.any?
|
||||
if aux_conditions.any?
|
||||
relation.where!(association_name => aux_conditions)
|
||||
end
|
||||
|
||||
relation.first.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,58 +0,0 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ActiveRecord
|
||||
module AuxTable
|
||||
class BaseTableDefinition
|
||||
delegate_missing_to :inner
|
||||
|
||||
def initialize(schema, base_table_name, inner)
|
||||
@schema = schema
|
||||
@base_table_name = base_table_name
|
||||
@inner = inner
|
||||
end
|
||||
|
||||
def create_aux(aux_name, **options, &block)
|
||||
@schema.create_aux_table(@base_table_name, aux_name, **options, &block)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :schema, :inner
|
||||
end
|
||||
|
||||
module MigrationExtensions
|
||||
def create_base_table(name, type: :string, **options)
|
||||
create_table(name, **options) do |t|
|
||||
t.column :type, type, null: false
|
||||
t = BaseTableDefinition.new(self, name, t)
|
||||
yield t
|
||||
end
|
||||
end
|
||||
|
||||
def change_base_table(name, **options)
|
||||
change_table(name, **options) do |t|
|
||||
yield BaseTableDefinition.new(self, name, t)
|
||||
end
|
||||
end
|
||||
|
||||
def create_aux_table(base_table, name, **options)
|
||||
aux_table_name = "#{base_table}_#{name}_aux"
|
||||
create_table(aux_table_name, id: false, **options) do |t|
|
||||
t.references :base_table,
|
||||
primary_key: true,
|
||||
null: false,
|
||||
foreign_key: {
|
||||
to_table: base_table,
|
||||
validate: true
|
||||
}
|
||||
yield t
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ActiveRecord::Migration
|
||||
include ActiveRecord::AuxTable::MigrationExtensions
|
||||
end
|
||||
117
lib/aux_table/auto_join_queries.rb
Normal file
117
lib/aux_table/auto_join_queries.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HasAuxTable
|
||||
module QueryExtensions
|
||||
extend T::Sig
|
||||
|
||||
# Get all aux column names for this model
|
||||
def get_aux_column_names(aux_table_name)
|
||||
config = @aux_table_configs[aux_table_name]
|
||||
return [] unless config&.model_class
|
||||
|
||||
config.model_class.column_names.reject do |col|
|
||||
%w[base_table_id created_at updated_at].include?(col)
|
||||
end
|
||||
end
|
||||
|
||||
# Split conditions into main table and aux table conditions
|
||||
def split_conditions(conditions, association_name)
|
||||
aux_columns = self.get_aux_column_names(association_name)
|
||||
main_conditions = {}
|
||||
aux_conditions = {}
|
||||
|
||||
conditions.each do |key, value|
|
||||
if aux_columns.include?(key.to_s)
|
||||
aux_conditions[key] = value
|
||||
else
|
||||
main_conditions[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
[main_conditions, aux_conditions]
|
||||
end
|
||||
|
||||
sig do
|
||||
params(
|
||||
on: T.any(ActiveRecord::Relation, T.class_of(ActiveRecord::Base)),
|
||||
aux_config: AuxTableConfig,
|
||||
with_bind_attribute: T::Boolean
|
||||
).void
|
||||
end
|
||||
def setup_query_extensions!(on, aux_config, with_bind_attribute: true)
|
||||
association_name = aux_config.aux_association_name
|
||||
on.define_singleton_method(:where) do |*args|
|
||||
if args.first.is_a?(Hash)
|
||||
relation = self.eager_load(association_name)
|
||||
self.apply_split_conditions!(relation, args)
|
||||
self.setup_query_extensions!(relation, aux_config)
|
||||
relation
|
||||
else
|
||||
super(*args)
|
||||
end
|
||||
end
|
||||
|
||||
unscoped_method = on.method(:unscoped)
|
||||
on.define_singleton_method(:unscoped) do
|
||||
relation = unscoped_method.call
|
||||
self.setup_query_extensions!(relation, aux_config)
|
||||
relation
|
||||
end
|
||||
|
||||
if with_bind_attribute
|
||||
bind_attribute_method = on.method(:bind_attribute)
|
||||
on.define_singleton_method(:bind_attribute) do |name, value, &block|
|
||||
aux_column_names = self.get_aux_column_names(association_name)
|
||||
if aux_column_names.include?(name.to_s)
|
||||
attr = aux_config.model_class.arel_table[name]
|
||||
bind =
|
||||
aux_config.model_class.predicate_builder.build_bind_attribute(
|
||||
attr.name,
|
||||
value
|
||||
)
|
||||
|
||||
block.call(attr, bind)
|
||||
else
|
||||
bind_attribute_method.call(name, value, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on.define_singleton_method(:find_by) do |*args|
|
||||
relation = self.eager_load(association_name)
|
||||
self.apply_split_conditions!(relation, args)
|
||||
relation.first
|
||||
end
|
||||
|
||||
on.define_singleton_method(:apply_split_conditions!) do |relation, args|
|
||||
conditions = args.first
|
||||
main_conditions, aux_conditions =
|
||||
self.split_conditions(conditions, association_name)
|
||||
relation.where!(main_conditions) if main_conditions.any?
|
||||
if aux_conditions.any?
|
||||
relation.where!(association_name => aux_conditions)
|
||||
end
|
||||
end
|
||||
|
||||
on.define_singleton_method(:find) do |*args|
|
||||
self.eager_load(association_name).find(*args)
|
||||
end
|
||||
|
||||
on.define_singleton_method(:exists?) do |*args|
|
||||
conditions = args.first || {}
|
||||
main_conditions, aux_conditions =
|
||||
self.split_conditions(conditions, association_name)
|
||||
puts "checking with conditions: #{main_conditions} / #{aux_conditions}"
|
||||
|
||||
relation = self.select("1").joins(association_name)
|
||||
relation.where!(main_conditions) if main_conditions.any?
|
||||
if aux_conditions.any?
|
||||
relation.where!(association_name => aux_conditions)
|
||||
end
|
||||
|
||||
relation.first.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
56
lib/aux_table/migration_extensions.rb
Normal file
56
lib/aux_table/migration_extensions.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HasAuxTable
|
||||
class BaseTableDefinition
|
||||
delegate_missing_to :inner
|
||||
|
||||
def initialize(schema, base_table_name, inner)
|
||||
@schema = schema
|
||||
@base_table_name = base_table_name
|
||||
@inner = inner
|
||||
end
|
||||
|
||||
def create_aux(aux_name, **options, &block)
|
||||
@schema.create_aux_table(@base_table_name, aux_name, **options, &block)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :schema, :inner
|
||||
end
|
||||
|
||||
module MigrationExtensions
|
||||
def create_base_table(name, type: :string, **options)
|
||||
create_table(name, **options) do |t|
|
||||
t.column :type, type, null: false
|
||||
t = BaseTableDefinition.new(self, name, t)
|
||||
yield t
|
||||
end
|
||||
end
|
||||
|
||||
def change_base_table(name, **options)
|
||||
change_table(name, **options) do |t|
|
||||
yield BaseTableDefinition.new(self, name, t)
|
||||
end
|
||||
end
|
||||
|
||||
def create_aux_table(base_table, name, **options)
|
||||
aux_table_name = "#{base_table}_#{name}_aux"
|
||||
create_table(aux_table_name, id: false, **options) do |t|
|
||||
t.references :base_table,
|
||||
primary_key: true,
|
||||
null: false,
|
||||
foreign_key: {
|
||||
to_table: base_table,
|
||||
validate: true
|
||||
}
|
||||
yield t
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ActiveRecord::Migration
|
||||
include HasAuxTable::MigrationExtensions
|
||||
end
|
||||
321
lib/has_aux_table.rb
Normal file
321
lib/has_aux_table.rb
Normal file
@@ -0,0 +1,321 @@
|
||||
# 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(
|
||||
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
|
||||
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].each do |method_name|
|
||||
# override _read_attribute to delegate auxiliary columns to the auxiliary table
|
||||
read_attribute_method = self.instance_method(method_name)
|
||||
self.define_method(method_name) do |name|
|
||||
if aux_columns_hash.include?(name.to_s)
|
||||
target = aux_config.ensure_aux_target(self)
|
||||
target.send(method_name, name)
|
||||
else
|
||||
read_attribute_method.bind(self).call(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
initialize_method = self.instance_method(:initialize)
|
||||
self.define_method(:initialize) do |args|
|
||||
aux_args, main_args =
|
||||
args.partition { |k, _| aux_columns_hash.key?(k.to_s) }.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)
|
||||
)
|
||||
# ActiveRecord::Base.transaction do
|
||||
# aux_model = aux_config.ensure_aux_target(self)
|
||||
# result = reload_method.bind(self).call(*args)
|
||||
# self.send(:"#{aux_config.aux_association_name}=", aux_model)
|
||||
# end
|
||||
# fresh_model =
|
||||
# result
|
||||
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
|
||||
# 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
|
||||
Reference in New Issue
Block a user