Fix query extensions and test failures

- Fix chained where clauses by extending ActiveRecord::Relation objects with auxiliary table support
- Fix nil value queries by using LEFT JOIN instead of INNER JOIN
- Fix non-existent column error handling by forcing query execution
- All 47 tests now pass, no regressions
- Sorbet typechecker passes with no errors
- Update backlog tasks to reflect completed work
This commit is contained in:
Dylan Knutson
2025-07-13 04:47:27 +00:00
parent 4e576d2a59
commit cde0896e98
8 changed files with 635 additions and 147 deletions

View File

@@ -8,6 +8,7 @@ require "active_support/concern"
module ActiveRecord
module AuxTable
extend T::Sig
extend T::Helpers
VERSION = "0.1.0"
@@ -66,7 +67,7 @@ module ActiveRecord
T.let({}, T.nilable(T::Hash[Symbol, Configuration]))
end
class_methods do
module ClassMethods
extend T::Sig
# Accessor methods for aux table configurations
@@ -111,6 +112,9 @@ module ActiveRecord
# Set up automatic auxiliary record creation and loading
setup_automatic_aux_record_handling(table_name_sym)
# Set up query extensions for automatic joins
setup_query_extensions(table_name_sym)
config
end
@@ -132,6 +136,209 @@ 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)
@@ -148,32 +355,49 @@ module ActiveRecord
# After schema is loaded, generate auxiliary attribute accessors
aux_config = aux_table_configurations[table_name]
if aux_config && aux_config.model_class
begin
# Force the auxiliary model to load its schema too
aux_config.model_class.load_schema
# Force the auxiliary model to load its schema too
aux_config.model_class.load_schema
# Get auxiliary columns (excluding system columns and columns that exist on main table)
aux_columns =
aux_config.model_class.column_names.reject do |col|
%w[id created_at updated_at].include?(col) ||
col.to_s.end_with?("_id") ||
T.unsafe(self).column_names.include?(col)
end
# Validate no column overlaps between main table and auxiliary table
main_columns = T.unsafe(self).column_names
aux_columns = aux_config.model_class.column_names
# Generate attribute accessors for each auxiliary column
aux_columns.each do |column_name|
unless T.unsafe(self).method_defined?(column_name)
define_aux_attribute_getter(column_name, association_name)
define_aux_attribute_setter(column_name, association_name)
define_aux_attribute_presence_check(
column_name,
association_name
)
end
# Find overlapping columns (excluding system columns and foreign keys)
overlapping_columns =
aux_columns.select do |col|
main_columns.include?(col) &&
!%w[id created_at updated_at].include?(col) &&
!col.to_s.end_with?("_id")
end
if overlapping_columns.any?
column_list =
overlapping_columns.map { |col| "'#{col}'" }.join(", ")
Kernel.raise ArgumentError,
"Auxiliary table '#{aux_config.model_class.table_name}' defines column(s) #{column_list} " \
"that already exist(s) in main table '#{T.unsafe(self).table_name}'. " \
"Auxiliary table columns must not overlap with main table columns."
end
# Get auxiliary columns (excluding system columns and foreign keys)
aux_columns =
aux_config.model_class.column_names.reject do |col|
%w[id created_at updated_at].include?(col) ||
col.to_s.end_with?("_id")
end
# Generate attribute accessors for each auxiliary column
aux_columns.each do |column_name|
unless T.unsafe(self).method_defined?(column_name)
define_aux_attribute_getter(column_name, association_name)
define_aux_attribute_setter(column_name, association_name)
define_aux_attribute_presence_check(
column_name,
association_name
)
end
rescue StandardError
# If auxiliary schema loading fails, continue without auxiliary attributes
end
end
@@ -283,6 +507,7 @@ module ActiveRecord
aux_model_class
end
end
mixes_in_class_methods(ClassMethods)
# Instance methods for working with auxiliary tables
sig { params(table_name: T.any(String, Symbol)).returns(T.untyped) }