id column rewriting

This commit is contained in:
Dylan Knutson
2025-07-29 16:18:37 +00:00
parent 94c2fb9593
commit 3016a17480
6 changed files with 207 additions and 101 deletions

View File

@@ -166,6 +166,7 @@ module HasAuxTable
next next
end end
else else
# TODO - add support for ActiveRecord::Reflection::ThroughReflection
raise "unsupported association type: #{assoc.class}" raise "unsupported association type: #{assoc.class}"
end end
end end

View File

@@ -13,12 +13,12 @@ module HasAuxTable
column_names.include?(name.to_s) column_names.include?(name.to_s)
end end
sig { params(name: String).returns(T::Boolean) } sig { params(name: T.any(String, Symbol)).returns(T::Boolean) }
def is_primary_key?(name) def is_primary_key?(name)
primary_keys.include?(name.to_sym) primary_keys.include?(name.to_sym)
end end
sig { params(name: String).returns(T::Boolean) } sig { params(name: T.any(String, Symbol)).returns(T::Boolean) }
def is_type_key?(name) def is_type_key?(name)
type_key == name.to_s type_key == name.to_s
end end

View File

@@ -51,18 +51,24 @@ class ActiveRecord::Associations::AssociationScope
klass = refl.klass klass = refl.klass
next unless klass.is_a?(HasAuxTable::ClassMethods) next unless klass.is_a?(HasAuxTable::ClassMethods)
next unless aux_config = klass.aux_table_for(refl.join_primary_key) next unless aux_config = klass.aux_table_for(refl.join_primary_key)
aux_table = aux_config.aux.klass.table_name aux_table =
main_table = aux_config.main.klass.table_name scope.connection.quote_table_name(aux_config.aux.klass.table_name)
main_table =
scope.connection.quote_table_name(aux_config.main.klass.table_name)
main_keys = main_keys =
aux_config.main.primary_keys.map { |key| "'#{main_table}'.'#{key}'" } aux_config.main.primary_keys.map do |key|
"#{main_table}.#{scope.connection.quote_column_name(key)}"
end
aux_keys = aux_keys =
aux_config.aux.primary_keys.map { |key| "'#{aux_table}'.'#{key}'" } aux_config.aux.primary_keys.map do |key|
"#{aux_table}.#{scope.connection.quote_column_name(key)}"
end
join_clause = join_clause =
main_keys main_keys
.zip(aux_keys) .zip(aux_keys)
.map { |(main_key, aux_key)| "#{main_key} = #{aux_key}" } .map { |(main_key, aux_key)| "#{main_key} = #{aux_key}" }
.join(" AND ") .join(" AND ")
scope.joins!("INNER JOIN '#{main_table}' ON (#{join_clause})") scope.joins!("INNER JOIN #{main_table} ON (#{join_clause})")
end if association.is_a?( end if association.is_a?(
ActiveRecord::Associations::HasManyThroughAssociation ActiveRecord::Associations::HasManyThroughAssociation
) )
@@ -122,7 +128,26 @@ module HasAuxTable
relation_class.send(:define_method, :pluck) do |*column_names| relation_class.send(:define_method, :pluck) do |*column_names|
T.bind(self, ActiveRecord::Relation) T.bind(self, ActiveRecord::Relation)
if (predicates = Util.try_relation_optimization(self, aux_config)) if (predicates = Util.try_relation_optimization(self, aux_config))
aux_relation = aux_config.aux.klass.where(predicates) aux_relation = aux_config.aux.klass.all
column_names.map! do |column_name|
if aux_config.main.is_primary_key?(column_name)
aux_table_name =
self.connection.quote_table_name(
aux_config.aux.klass.table_name
)
pkey_column_name =
self.connection.quote_column_name(
aux_config.aux.klass.primary_key
)
column_name = self.connection.quote_column_name(column_name)
Arel.sql(
"#{aux_table_name}.#{pkey_column_name} AS #{column_name}"
)
else
column_name
end
end
aux_relation.where!(predicates) if predicates.any?
aux_relation.pluck(*column_names) aux_relation.pluck(*column_names)
else else
pluck_method.bind(self).call(*column_names) pluck_method.bind(self).call(*column_names)
@@ -133,10 +158,15 @@ module HasAuxTable
relation_class.send( relation_class.send(
:define_method, :define_method,
:calculate :calculate
) do |operation, column_name| ) do |operation, column_name = nil|
T.bind(self, ActiveRecord::Relation) T.bind(self, ActiveRecord::Relation)
if (predicates = Util.try_relation_optimization(self, aux_config)) if (predicates = Util.try_relation_optimization(self, aux_config))
aux_relation = aux_config.aux.klass.where(predicates) aux_relation = aux_config.aux.klass.all
if column_name && aux_config.main.is_primary_key?(column_name)
column_name = aux_config.aux.klass.primary_key
end
aux_relation.where!(predicates) if predicates.any?
aux_relation.calculate(operation, column_name) aux_relation.calculate(operation, column_name)
else else
calculate_method.bind(self).call(operation, column_name) calculate_method.bind(self).call(operation, column_name)

View File

@@ -235,7 +235,7 @@ RSpec.describe HasAuxTable do
it "allows saving the model with auxiliary columns" do it "allows saving the model with auxiliary columns" do
car = Car.create!(name: "Honda Civic") car = Car.create!(name: "Honda Civic")
queries = queries =
SpecHelper.capture_queries do capture_queries do
car.fuel_type = "hybrid" car.fuel_type = "hybrid"
car.engine_size = 1.8 car.engine_size = 1.8
car.save! car.save!
@@ -424,7 +424,7 @@ RSpec.describe HasAuxTable do
it "loads single model with auxiliary data in one query using find" do it "loads single model with auxiliary data in one query using find" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
car = Car.find(@car1.id) car = Car.find(@car1.id)
# Access auxiliary attributes to ensure they're loaded # Access auxiliary attributes to ensure they're loaded
car.fuel_type car.fuel_type
@@ -436,7 +436,7 @@ RSpec.describe HasAuxTable do
it "loads single model with auxiliary data in one query using find_by" do it "loads single model with auxiliary data in one query using find_by" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
car = Car.find_by(name: "Toyota Prius") car = Car.find_by(name: "Toyota Prius")
# Access auxiliary attributes to ensure they're loaded # Access auxiliary attributes to ensure they're loaded
car.fuel_type car.fuel_type
@@ -448,7 +448,7 @@ RSpec.describe HasAuxTable do
it "loads multiple models with auxiliary data in one query using where" do it "loads multiple models with auxiliary data in one query using where" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
cars = Car.where(fuel_type: %w[hybrid electric]) cars = Car.where(fuel_type: %w[hybrid electric])
# Access auxiliary attributes for all cars # Access auxiliary attributes for all cars
cars.each do |car| cars.each do |car|
@@ -473,7 +473,7 @@ RSpec.describe HasAuxTable do
cars = nil cars = nil
queries = queries =
SpecHelper.capture_queries do capture_queries do
cars = Car.where(fuel_type: "gasoline") cars = Car.where(fuel_type: "gasoline")
# Access auxiliary attributes for all cars - should not trigger additional queries # Access auxiliary attributes for all cars - should not trigger additional queries
cars.each do |car| cars.each do |car|
@@ -489,7 +489,7 @@ RSpec.describe HasAuxTable do
it "uses single query when ordering by auxiliary columns" do it "uses single query when ordering by auxiliary columns" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
cars = Car.where(engine_size: 1.0..3.0).order(:engine_size) cars = Car.where(engine_size: 1.0..3.0).order(:engine_size)
# Access all attributes # Access all attributes
cars.each do |car| cars.each do |car|
@@ -504,7 +504,7 @@ RSpec.describe HasAuxTable do
it "uses single query for complex auxiliary column queries" do it "uses single query for complex auxiliary column queries" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
cars = cars =
Car.where(fuel_type: "hybrid").or(Car.where(engine_size: 0.0)) Car.where(fuel_type: "hybrid").or(Car.where(engine_size: 0.0))
# Access all attributes # Access all attributes
@@ -520,7 +520,7 @@ RSpec.describe HasAuxTable do
it "uses single query when finding by auxiliary columns" do it "uses single query when finding by auxiliary columns" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
car = Car.find_by(fuel_type: "hybrid", name: "Toyota Prius") car = Car.find_by(fuel_type: "hybrid", name: "Toyota Prius")
# Access all attributes # Access all attributes
car.name car.name
@@ -537,7 +537,7 @@ RSpec.describe HasAuxTable do
# Now count queries when accessing auxiliary attributes # Now count queries when accessing auxiliary attributes
queries = queries =
SpecHelper.capture_queries do capture_queries do
car.fuel_type car.fuel_type
car.engine_size car.engine_size
car.fuel_type? # presence check car.fuel_type? # presence check
@@ -550,7 +550,7 @@ RSpec.describe HasAuxTable do
it "handles mixed queries with main and auxiliary columns in single query" do it "handles mixed queries with main and auxiliary columns in single query" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
cars = Car.where(name: "Toyota Prius", fuel_type: "hybrid") cars = Car.where(name: "Toyota Prius", fuel_type: "hybrid")
cars.each do |car| cars.each do |car|
car.name car.name
@@ -564,7 +564,7 @@ RSpec.describe HasAuxTable do
it "uses single query for range queries on auxiliary columns" do it "uses single query for range queries on auxiliary columns" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
cars = Car.where(engine_size: 0.0..1.9) cars = Car.where(engine_size: 0.0..1.9)
cars.each do |car| cars.each do |car|
car.name car.name
@@ -578,7 +578,7 @@ RSpec.describe HasAuxTable do
it "maintains single query performance with limit and offset" do it "maintains single query performance with limit and offset" do
queries = queries =
SpecHelper.capture_queries do capture_queries do
car = Car.where(fuel_type: %w[hybrid electric]).limit(1).first car = Car.where(fuel_type: %w[hybrid electric]).limit(1).first
# Access auxiliary attributes # Access auxiliary attributes
car.fuel_type car.fuel_type
@@ -983,7 +983,7 @@ RSpec.describe HasAuxTable do
end end
it "reloads with one query" do it "reloads with one query" do
queries = SpecHelper.capture_queries { @car.reload } queries = capture_queries { @car.reload }
expect(queries.length).to eq(1) expect(queries.length).to eq(1)
end end
@@ -1027,16 +1027,12 @@ RSpec.describe HasAuxTable do
expect(Car.count).to eq(1) expect(Car.count).to eq(1)
expect(Boat.count).to eq(1) expect(Boat.count).to eq(1)
expect( expect(capture_queries { car = Vehicle.find(car.id) }.length).to eq(1)
SpecHelper.capture_queries { car = Vehicle.find(car.id) }.length
).to eq(1)
expect(car.fuel_type).to eq("gasoline") expect(car.fuel_type).to eq("gasoline")
expect(car.engine_size).to eq(2.0) expect(car.engine_size).to eq(2.0)
expect(car.name).to eq("Honda Civic") expect(car.name).to eq("Honda Civic")
expect( expect(capture_queries { boat = Vehicle.find(boat.id) }.length).to eq(1)
SpecHelper.capture_queries { boat = Vehicle.find(boat.id) }.length
).to eq(1)
expect(boat.only_freshwater).to eq(true) expect(boat.only_freshwater).to eq(true)
end end

View File

@@ -17,84 +17,105 @@ RSpec.describe "loading optimizations" do
shared_examples "queries only the aux table" do shared_examples "queries only the aux table" do
it "queries only the aux table" do it "queries only the aux table" do
expect(@queries.length).to eq(1) expect(queries.length).to eq(1)
expect(@queries.first).not_to include("JOIN") expect(queries.first).not_to include("JOIN")
expect(@queries.first).to match(/\bvehicles_car_aux\b/) expect(queries.first).to match(/\bvehicles_car_aux\b/)
expect(@queries.first).not_to match(/\bvehicles\b/) expect(queries.first).not_to match(/\bvehicles\b/)
end end
end end
shared_examples "queries both tables" do shared_examples "queries both tables" do
it "queries both tables" do it "queries both tables" do
expect(@queries.length).to eq(1) expect(queries.length).to eq(1)
expect(@queries.first).to include("JOIN") expect(queries.first).to include("JOIN")
expect(@queries.first).to match(/\bvehicles\b/) expect(queries.first).to match(/\bvehicles\b/)
expect(@queries.first).to match(/\bvehicles_car_aux\b/) expect(queries.first).to match(/\bvehicles_car_aux\b/)
end end
end end
describe "pluck" do describe "pluck" do
context "aux columns are referenced" do context "aux columns are referenced" do
before do let_and_capture(:queries) { Car.pluck(:fuel_type) }
@queries =
SpecHelper.capture_queries do it "returns the correct result" do
expect(Car.pluck(:fuel_type)).to eq(%w[gasoline hybrid electric]) expect(queries.result).to eq(%w[gasoline hybrid electric])
end
end end
it_behaves_like "queries only the aux table" it_behaves_like "queries only the aux table"
end end
context "aux columns are chained on a where clause" do context "aux columns are chained on a where clause" do
before do let_and_capture(:queries) do
@queries = Car.where(engine_size: 1.4..1.9).pluck(:fuel_type)
SpecHelper.capture_queries do end
expect(Car.where(engine_size: 1.4..1.9).pluck(:fuel_type)).to eq(
%w[hybrid electric] it "returns the correct result" do
) expect(queries.result).to eq(%w[hybrid electric])
end
end end
it_behaves_like "queries only the aux table" it_behaves_like "queries only the aux table"
it "applies the BETWEEN clause" do it "applies the BETWEEN clause" do
expect(@queries.first).to include("BETWEEN") expect(queries.first).to include("BETWEEN")
end end
end end
context "main table columns are referenced" do context "main table columns are referenced" do
before do let(:queries) do
@queries = capture_queries { Car.where(name: "Toyota Camry").pluck(:fuel_type) }
SpecHelper.capture_queries do end
expect(Car.where(name: "Toyota Camry").pluck(:fuel_type)).to eq(
%w[gasoline] it "returns the correct result" do
) expect(queries.result).to eq(%w[gasoline])
end
end end
it_behaves_like "queries both tables" it_behaves_like "queries both tables"
end end
context "main table columns are chained on a where clause" do context "main table columns are chained on a where clause" do
before do let(:queries) do
@queries = capture_queries { Car.where(name: "Toyota Camry").pluck(:fuel_type) }
SpecHelper.capture_queries do end
expect(Car.where(name: "Toyota Camry").pluck(:fuel_type)).to eq(
%w[gasoline] it "returns the correct result" do
) expect(queries.result).to eq(%w[gasoline])
end
end end
it_behaves_like "queries both tables" it_behaves_like "queries both tables"
end end
context "multiple columns" do context "multiple columns" do
before do let(:queries) do
@queries = capture_queries { Car.pluck(:fuel_type, :engine_size) }
SpecHelper.capture_queries do end
expect(Car.pluck(:fuel_type, :engine_size)).to eq(
[["gasoline", 2.0], ["hybrid", 1.5], ["electric", 1.8]] it "returns the correct result" do
) expect(queries.result).to eq(
end [["gasoline", 2.0], ["hybrid", 1.5], ["electric", 1.8]]
)
end
it_behaves_like "queries only the aux table"
end
context "multiple columns with a where clause" do
let(:queries) do
capture_queries do
Car.where(name: "Toyota Camry").pluck(:fuel_type, :engine_size)
end
end
it "returns the correct result" do
expect(queries.result).to eq([["gasoline", 2.0]])
end
it_behaves_like "queries both tables"
end
context "querying the id column" do
let_and_capture(:queries) { Car.pluck(:id) }
it "renames the base_table_id to id" do
expect(queries.first).to include("\"base_table_id\" AS \"id\"")
end end
it_behaves_like "queries only the aux table" it_behaves_like "queries only the aux table"
@@ -103,50 +124,56 @@ RSpec.describe "loading optimizations" do
describe "maximum" do describe "maximum" do
context "aux columns are referenced" do context "aux columns are referenced" do
before do let_and_capture(:queries) { Car.maximum(:engine_size) }
@queries =
SpecHelper.capture_queries do
expect(Car.maximum(:engine_size)).to eq(2.0)
end
end
it_behaves_like "queries only the aux table" it_behaves_like "queries only the aux table"
end end
context "aux columns are chained on a where clause" do context "aux columns are chained on a where clause" do
before do let_and_capture(:queries) do
@queries = Car.where(engine_size: 1.4..1.9).maximum(:engine_size)
SpecHelper.capture_queries do end
expect(
Car.where(engine_size: 1.4..1.9).maximum(:engine_size) it "returns the correct result" do
).to eq(1.8) expect(queries.result).to eq(1.8)
end
end end
it_behaves_like "queries only the aux table" it_behaves_like "queries only the aux table"
end end
context "main table columns are referenced" do context "main table columns are referenced" do
before do let_and_capture(:queries) do
@queries = Car.where(name: "Toyota Camry").maximum(:engine_size)
SpecHelper.capture_queries do end
expect(
Car.where(name: "Toyota Camry").maximum(:engine_size) it "returns the correct result" do
).to eq(2.0) expect(queries.result).to eq(2.0)
end
end end
it_behaves_like "queries both tables" it_behaves_like "queries both tables"
end end
context "id column is referenced" do
let_and_capture(:queries) { Car.maximum(:id) }
it "renames the base_table_id to id" do
expect(queries.first).to include('"base_table_id"')
end
it "has the right result" do
expect(queries.result).to eq(3)
end
it_behaves_like "queries only the aux table"
end
context "main table columns are chained on a where clause" do context "main table columns are chained on a where clause" do
before do let_and_capture(:queries) do
@queries = Car.where(name: "Toyota Camry").maximum(:engine_size)
SpecHelper.capture_queries do end
expect(
Car.where(name: "Toyota Camry").maximum(:engine_size) it "returns the correct result" do
).to eq(2.0) expect(queries.result).to eq(2.0)
end
end end
it_behaves_like "queries both tables" it_behaves_like "queries both tables"

View File

@@ -54,6 +54,36 @@ RSpec.configure do |config|
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
end end
config.include(
Module.new do
extend T::Sig
sig do
type_parameters(:T)
.params(block: T.proc.returns(T.type_parameter(:T)))
.returns(SpecHelper::CaptureQueries[T.type_parameter(:T)])
end
def capture_queries(&block)
SpecHelper.capture_queries(&block)
end
end
)
config.extend(
Module.new do
extend T::Sig
sig do
type_parameters(:T)
.params(binding: Symbol, block: T.proc.returns(T.type_parameter(:T)))
.returns(T.type_parameter(:T))
end
def let_and_capture(binding, &block)
T.bind(self, RSpec::Core::MemoizedHelpers::ClassMethods)
let(binding) { SpecHelper.capture_queries(&block) }
end
end
)
end end
module SpecHelper module SpecHelper
@@ -61,8 +91,28 @@ module SpecHelper
extend T::Helpers extend T::Helpers
LOG_QUERIES = T.let(false, T::Boolean) LOG_QUERIES = T.let(false, T::Boolean)
class CaptureQueries < Array
extend T::Sig
extend T::Generic
Elem = type_member { { fixed: String } }
Result = type_member
sig { params(queries: T::Array[String], result: Result).void }
def initialize(queries, result)
super(queries)
@result = result
end
sig { returns(Result) }
attr_reader :result
end
# Helper method to count queries # Helper method to count queries
sig { params(block: T.proc.void).returns(T::Array[String]) } sig do
type_parameters(:T)
.params(block: T.proc.returns(T.type_parameter(:T)))
.returns(CaptureQueries[T.type_parameter(:T)])
end
def self.capture_queries(&block) def self.capture_queries(&block)
queries = T.let([], T::Array[String]) queries = T.let([], T::Array[String])
query_callback = query_callback =
@@ -70,6 +120,7 @@ module SpecHelper
queries << values[:sql] queries << values[:sql]
end end
result = T.let(nil, T.untyped)
ActiveSupport::Notifications.subscribed( ActiveSupport::Notifications.subscribed(
query_callback, query_callback,
"sql.active_record" "sql.active_record"
@@ -79,10 +130,11 @@ module SpecHelper
ActiveRecord::Base.logger = Logger.new(STDOUT) ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Base.logger.level = Logger::DEBUG ActiveRecord::Base.logger.level = Logger::DEBUG
end end
block.call result = block.call
ensure
ActiveRecord::Base.logger = old_logger if LOG_QUERIES ActiveRecord::Base.logger = old_logger if LOG_QUERIES
end end
queries CaptureQueries.new(queries, result)
end end
end end