Extract query extensions into separate module and fix infinite recursion

- Extract setup_query_extensions and related methods to new QueryExtensions module
- Reduce main aux_table.rb file from 537 to 335 lines (202 lines removed)
- Create lib/active_record/aux_table/query_extensions.rb with RelationMethods
- Fix infinite recursion bug by using super() calls and disabling aux processing on joined relations
- All 47 tests pass and no Sorbet type errors
- Improve code organization and maintainability
- Complete task-19 with all acceptance criteria met

Closes: task-19
This commit is contained in:
Dylan Knutson
2025-07-13 05:02:20 +00:00
parent cde0896e98
commit f8b9b847e5
3 changed files with 232 additions and 203 deletions

View File

@@ -4,6 +4,7 @@
require "sorbet-runtime"
require "active_support"
require "active_support/concern"
require_relative "aux_table/query_extensions"
module ActiveRecord
module AuxTable
@@ -69,6 +70,7 @@ module ActiveRecord
module ClassMethods
extend T::Sig
include QueryExtensions
# Accessor methods for aux table configurations
sig { returns(T::Hash[Symbol, Configuration]) }
@@ -136,209 +138,6 @@ module ActiveRecord
private
# Set up query extensions for automatic joins
sig { params(table_name: Symbol).void }
def setup_query_extensions(table_name)
# Get the association name for the auxiliary table
aux_table_association_name =
T
.must(aux_table_configurations[table_name])
.table_name
.to_s
.singularize
.to_sym
# Prepend a module to override query methods on the class
# Use T.unsafe for the entire module creation since Sorbet can't properly type check
# dynamically created modules that get prepended to ActiveRecord classes
T
.unsafe(self)
.singleton_class
.prepend(
T.unsafe(
Module.new do
T.bind(self, T.untyped)
# Override find to automatically include auxiliary table joins
define_method(:find) do |*args|
if has_aux_tables?
joins(aux_table_association_name).find(*args)
else
super(*args)
end
end
# Override find_by to automatically include auxiliary table joins
define_method(:find_by) do |*args|
if has_aux_tables? && args.first.is_a?(Hash)
conditions = args.first
if contains_aux_columns?(conditions)
# Split conditions between main table and auxiliary table
main_conditions = {}
aux_conditions = {}
conditions.each do |key, value|
if aux_column_names.include?(key.to_s)
aux_conditions[key] = value
else
main_conditions[key] = value
end
end
# Build query with automatic join
query = joins(aux_table_association_name)
query =
query.where(main_conditions) if main_conditions.any?
query =
query.where(
aux_table_association_name => aux_conditions
) if aux_conditions.any?
query.first
else
joins(aux_table_association_name).find_by(*args)
end
else
super(*args)
end
end
# Override where to handle auxiliary columns
define_method(:where) do |*args|
if has_aux_tables? && args.first.is_a?(Hash)
conditions = args.first
aux_columns = aux_column_names
# Validate that all auxiliary columns exist
aux_conditions = {}
main_conditions = {}
conditions.each do |key, value|
if aux_columns.include?(key.to_s)
aux_conditions[key] = value
elsif column_names.include?(key.to_s)
main_conditions[key] = value
else
# Column doesn't exist in either table - let ActiveRecord handle the error
# by executing the query which will raise StatementInvalid
result = super(*args)
# Force execution to trigger the error
result.load
return result
end
end
if aux_conditions.any?
# Build query with automatic join
# Use LEFT JOIN to handle nil values properly
query = left_joins(aux_table_association_name)
query =
query.where(main_conditions) if main_conditions.any?
query =
query.where(
aux_table_association_name => aux_conditions
) if aux_conditions.any?
# Extend the returned relation with auxiliary table support
extend_relation_with_aux_table_support(
query,
aux_table_association_name
)
else
super(*args)
end
else
super(*args)
end
end
# Check if query contains auxiliary table columns
define_method(:contains_aux_columns?) do |conditions|
return false unless has_aux_tables?
conditions.keys.any? do |key|
aux_column_names.include?(key.to_s)
end
end
# Get auxiliary column names
define_method(:aux_column_names) do
return [] unless has_aux_tables?
ac = aux_table_configurations.values.first
return [] unless ac&.model_class
ac.model_class.column_names.reject do |col|
%w[id created_at updated_at].include?(col) ||
col.to_s.end_with?("_id")
end
end
# Extend relation with auxiliary table support
define_method(
:extend_relation_with_aux_table_support
) do |relation, aux_table_association_name|
# Create a module that extends the relation with auxiliary table support
aux_module =
Module.new do
define_method(:where) do |*args|
if args.first.is_a?(Hash)
conditions = args.first
aux_columns = relation.klass.send(:aux_column_names)
# Split conditions between main table and auxiliary table
main_conditions = {}
aux_conditions = {}
conditions.each do |key, value|
if aux_columns.include?(key.to_s)
aux_conditions[key] = value
elsif relation.klass.column_names.include?(key.to_s)
main_conditions[key] = value
else
# Column doesn't exist - let ActiveRecord handle the error
# by executing the query which will raise StatementInvalid
result = super(*args)
# Force execution to trigger the error
result.load
return result
end
end
if aux_conditions.any?
# Apply conditions to both tables
query = self
query =
query.where(
main_conditions
) if main_conditions.any?
query =
query.where(
aux_table_association_name => aux_conditions
) if aux_conditions.any?
# Recursively extend the new relation
relation.klass.send(
:extend_relation_with_aux_table_support,
query,
aux_table_association_name
)
else
super(*args)
end
else
super(*args)
end
end
end
relation.extend(aux_module)
relation
end
end
)
)
end
# Hook into schema loading to generate attribute accessors when schema is loaded
sig { params(table_name: Symbol).void }
def setup_schema_loading_hook(table_name)

View File

@@ -0,0 +1,197 @@
# typed: strict
# frozen_string_literal: true
require "sorbet-runtime"
module ActiveRecord
module AuxTable
module QueryExtensions
extend T::Sig
# Set up query extensions for automatic joins by prepending to ActiveRecord::Relation
sig { params(table_name: Symbol).void }
def setup_query_extensions(table_name)
# Only prepend once to ActiveRecord::Relation
unless ActiveRecord::Relation.ancestors.include?(
QueryExtensions::RelationMethods
)
ActiveRecord::Relation.prepend(QueryExtensions::RelationMethods)
end
end
# Module to be prepended to ActiveRecord::Relation
module RelationMethods
extend T::Sig
extend T::Helpers
requires_ancestor { ActiveRecord::Relation }
# Override find to automatically include auxiliary table joins
sig { params(args: T.untyped).returns(T.untyped) }
def find(*args)
if klass_has_aux_tables? && aux_table_association_name
# Use joins first, then call super on the joined relation
joined_relation = T.unsafe(self).joins(aux_table_association_name)
# Temporarily disable aux table processing to avoid recursion
T
.unsafe(joined_relation)
.singleton_class
.prepend(
Module.new do
def klass_has_aux_tables?
false
end
end
)
joined_relation.find(*T.unsafe(args))
else
T.unsafe(super(*T.unsafe(args)))
end
end
# Override find_by to automatically include auxiliary table joins
sig { params(args: T.untyped).returns(T.untyped) }
def find_by(*args)
if klass_has_aux_tables? && args.first.is_a?(Hash)
conditions = args.first
if contains_aux_columns?(conditions)
# Split conditions between main table and auxiliary table
main_conditions = {}
aux_conditions = {}
conditions.each do |key, value|
if aux_column_names.include?(key.to_s)
aux_conditions[key] = value
else
main_conditions[key] = value
end
end
# Build query with automatic join
query = T.unsafe(self).joins(aux_table_association_name)
query = query.where(main_conditions) if main_conditions.any?
query =
query.where(
aux_table_association_name => aux_conditions
) if aux_conditions.any?
query.first
else
joined_relation = T.unsafe(self).joins(aux_table_association_name)
# Temporarily disable aux table processing to avoid recursion
T
.unsafe(joined_relation)
.singleton_class
.prepend(
Module.new do
def klass_has_aux_tables?
false
end
end
)
joined_relation.find_by(*T.unsafe(args))
end
else
T.unsafe(super(*T.unsafe(args)))
end
end
# Override where to handle auxiliary columns
sig { params(args: T.untyped).returns(T.untyped) }
def where(*args)
if klass_has_aux_tables? && args.first.is_a?(Hash)
conditions = args.first
aux_columns = aux_column_names
# Validate that all auxiliary columns exist
aux_conditions = {}
main_conditions = {}
conditions.each do |key, value|
if aux_columns.include?(key.to_s)
aux_conditions[key] = value
elsif T.unsafe(self).klass.column_names.include?(key.to_s)
main_conditions[key] = value
else
# Column doesn't exist in either table - let ActiveRecord handle the error
# by executing the query which will raise StatementInvalid
result = T.unsafe(super(*T.unsafe(args)))
# Force execution to trigger the error
result.load
return result
end
end
if aux_conditions.any?
# Build query with automatic join
# Use LEFT JOIN to handle nil values properly
query = T.unsafe(self).left_joins(aux_table_association_name)
query = query.where(main_conditions) if main_conditions.any?
query =
query.where(
aux_table_association_name => aux_conditions
) if aux_conditions.any?
# Extend the returned relation with auxiliary table support for chaining
extend_relation_with_aux_table_support(query)
else
T.unsafe(super(*T.unsafe(args)))
end
else
T.unsafe(super(*T.unsafe(args)))
end
end
private
# Check if the model class has auxiliary tables configured
sig { returns(T::Boolean) }
def klass_has_aux_tables?
T.unsafe(self).klass.respond_to?(:aux_table_configurations) &&
T.unsafe(self).klass.aux_table_configurations.any?
end
# Get the auxiliary table association name for the model class
sig { returns(T.nilable(Symbol)) }
def aux_table_association_name
return nil unless klass_has_aux_tables?
config = T.unsafe(self).klass.aux_table_configurations.values.first
return nil unless config
config.table_name.to_s.singularize.to_sym
end
# Check if query contains auxiliary table columns
sig do
params(conditions: T::Hash[T.untyped, T.untyped]).returns(T::Boolean)
end
def contains_aux_columns?(conditions)
return false unless klass_has_aux_tables?
conditions.keys.any? { |key| aux_column_names.include?(key.to_s) }
end
# Get auxiliary column names for the model class
sig { returns(T::Array[String]) }
def aux_column_names
return [] unless klass_has_aux_tables?
config = T.unsafe(self).klass.aux_table_configurations.values.first
return [] unless config&.model_class
config.model_class.column_names.reject do |col|
%w[id created_at updated_at].include?(col) ||
col.to_s.end_with?("_id")
end
end
# Extend relation with auxiliary table support for chaining
sig { params(relation: T.untyped).returns(T.untyped) }
def extend_relation_with_aux_table_support(relation)
# The relation already has QueryExtensions::RelationMethods prepended,
# so it will handle chained where clauses automatically
relation
end
end
end
end
end