From 8854dddb4ad51d9a6c2cc7205ce6ed9c94f5ae6e Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Sun, 20 Jul 2025 17:56:08 +0000 Subject: [PATCH] counter cache support --- lib/has_aux_table.rb | 27 ++++- spec/active_record/has_aux_table_spec.rb | 25 ++++- spec/spec_helper.rb | 82 +--------------- spec/spec_models.rb | 120 ++++++++++++++++++++++- 4 files changed, 172 insertions(+), 82 deletions(-) diff --git a/lib/has_aux_table.rb b/lib/has_aux_table.rb index d933958..66fe2d2 100644 --- a/lib/has_aux_table.rb +++ b/lib/has_aux_table.rb @@ -58,6 +58,7 @@ module HasAuxTable setup_relation_extensions!(config) setup_attribute_getter_setter_hooks!(config) setup_enum_hook!(config) + setup_update_counter_hook!(config) config end @@ -236,7 +237,6 @@ module HasAuxTable read_attribute _write_attribute write_attribute - _assign_attribute ].each do |method_name| method = self.instance_method(method_name) self.define_method(method_name) do |name, *args, **kwargs, &block| @@ -413,6 +413,31 @@ module HasAuxTable end end end + + sig { params(config: AuxTableConfig).void } + def setup_update_counter_hook!(config) + self.define_singleton_method(:update_counters) do |id, counters| + T.bind(self, T.class_of(ActiveRecord::Base)) + main_counters = {} + aux_counters = {} + opts = {} + counters.each do |k, v| + is_aux = config.aux.is_column?(k) + is_main = config.main.is_column?(k) + if !is_aux && !is_main + opts[k] = v + elsif is_aux + aux_counters[k] = v + elsif is_main + main_counters[k] = v + end + end + super(id, main_counters.merge(opts)) if main_counters.any? + if aux_counters.any? + config.aux.klass.update_counters(id, aux_counters.merge(opts)) + end + end + end end mixes_in_class_methods(ClassMethods) end diff --git a/spec/active_record/has_aux_table_spec.rb b/spec/active_record/has_aux_table_spec.rb index e7fb1df..0515620 100644 --- a/spec/active_record/has_aux_table_spec.rb +++ b/spec/active_record/has_aux_table_spec.rb @@ -2,8 +2,6 @@ # frozen_string_literal: true RSpec.describe HasAuxTable do - before(:all) { SpecHelper.initialize_spec_schema! } - # Car class will be defined after schema setup it "has a version number" do @@ -848,4 +846,27 @@ RSpec.describe HasAuxTable do end }.not_to raise_error end + + describe "counter cache" do + before do + @reader = Reader.create!(name: "John Doe", reading_speed: 100) + @book = + Book.create!(title: "The Great Gatsby", author: "F. Scott Fitzgerald") + end + + it "updates counter caches that are on the aux model" do + @reader.read_books << @book + expect(@reader.read_book_joins_count).to eq(1) + expect(@reader.read_book_joins.count).to eq(1) + end + + it "updates counter caches that are on the main table of an aux model" do + end + + it "updates counter caches on a non-aux model" do + @reader.read_books << @book + expect(@book.read_book_joins_count).to eq(1) + expect(@book.read_book_joins.count).to eq(1) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 62913c5..41ba6d0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,6 +12,8 @@ ActiveRecord::Base.establish_connection( database: ":memory:" ) +require_relative "spec_models" + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" @@ -19,6 +21,8 @@ RSpec.configure do |config| # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! + # config.backtrace_inclusion_patterns = [/\bactiverecord\b/] + config.expect_with :rspec do |c| c.syntax = :expect end @@ -58,82 +62,4 @@ module SpecHelper query_count end - - sig { void } - def self.initialize_spec_schema! - # Set up the database schema for testing - ActiveRecord::Schema.define do - create_table :vehicle_lots do |t| - t.string :name - t.timestamps - end - - create_base_table :vehicles do |t| - t.string :name - t.references :vehicle_lot, foreign_key: { to_table: :vehicle_lots } - t.timestamps - - t.create_aux :car do |t| - t.string :fuel_type - t.decimal :engine_size, precision: 3, scale: 1 - end - - t.create_aux :boat do |t| - t.boolean :only_freshwater - end - - t.create_aux :plane do |t| - t.integer :engine_type - end - end - - create_base_table :people do |t| - t.string :name - t.timestamps - - t.create_aux :driver do |t| - t.integer :license_number - t.references :car, - foreign_key: { - to_table: :vehicles_car_aux, - primary_key: :base_table_id - } - end - - t.create_aux :captain do |t| - t.references :boat, - null: false, - foreign_key: { - to_table: :vehicles_boat_aux, - primary_key: :base_table_id - } - end - - t.create_aux :passenger do |t| - t.references :boat, - null: false, - foreign_key: { - to_table: :vehicles_boat_aux, - primary_key: :base_table_id - } - end - end - - create_base_table :utensils do |t| - t.string :name - t.string :material - t.timestamps - - t.create_aux :fork do |t| - t.integer :num_tongs - end - - t.create_aux :spoon do |t| - t.string :curvature - end - end - - require_relative "spec_models" - end - end end diff --git a/spec/spec_models.rb b/spec/spec_models.rb index 68ebef5..8cc54c6 100644 --- a/spec/spec_models.rb +++ b/spec/spec_models.rb @@ -1,5 +1,105 @@ # typed: strict # frozen_string_literal: true +extend T::Sig + +# Set up the database schema for testing +ActiveRecord::Schema.define do + create_table :vehicle_lots do |t| + t.string :name + t.timestamps + end + + create_base_table :vehicles do |t| + t.string :name + t.references :vehicle_lot, foreign_key: { to_table: :vehicle_lots } + t.timestamps + + t.create_aux :car do |t| + t.string :fuel_type + t.decimal :engine_size, precision: 3, scale: 1 + end + + t.create_aux :boat do |t| + t.boolean :only_freshwater + end + + t.create_aux :plane do |t| + t.integer :engine_type + end + end + + create_base_table :people do |t| + t.string :name + t.integer :friends_count + t.integer :lovers_count + t.timestamps + + t.create_aux :driver do |t| + t.integer :license_number + t.references :car, + foreign_key: { + to_table: :vehicles_car_aux, + primary_key: :base_table_id + } + end + + t.create_aux :captain do |t| + t.references :boat, + null: false, + foreign_key: { + to_table: :vehicles_boat_aux, + primary_key: :base_table_id + } + end + + t.create_aux :passenger do |t| + t.references :boat, + null: false, + foreign_key: { + to_table: :vehicles_boat_aux, + primary_key: :base_table_id + } + end + end + + create_base_table :utensils do |t| + t.string :name + t.string :material + t.timestamps + + t.create_aux :fork do |t| + t.integer :num_tongs + end + + t.create_aux :spoon do |t| + t.string :curvature + end + end + + create_aux_table :people, :reader do |t| + t.integer :reading_speed + t.integer :read_book_joins_count + end + + create_table :books do |t| + t.string :title + t.string :author + t.integer :pages + t.integer :read_book_joins_count + t.timestamps + end + + create_table :read_book_joins, + id: false, + primary_key: %i[book_id reader_id] do |t| + t.references :book, foreign_key: { to_table: :books } + t.references :reader, + foreign_key: { + to_table: :people_reader_aux, + primary_key: :base_table_id + } + end +end class Vehicle < ActiveRecord::Base include HasAuxTable @@ -28,7 +128,6 @@ end class Person < ActiveRecord::Base include HasAuxTable - self.table_name = "people" end class Driver < Person @@ -59,3 +158,22 @@ module Kitchen aux_table :spoon end end + +# Non-aux table model that has_and_belongs_to_many w/ counter cache +class Book < ActiveRecord::Base + has_many :read_book_joins, inverse_of: :book + has_many :readers, through: :read_book_joins +end + +# Aux table model that has_and_belongs_to_many w/ counter cache +class Reader < Person + aux_table :reader + has_many :read_book_joins, inverse_of: :reader + has_many :read_books, through: :read_book_joins, source: :book +end + +# The join table for the has_and_belongs_to_many association between Reader and Book +class ReadBookJoin < ActiveRecord::Base + belongs_to :book, counter_cache: true + belongs_to :reader, counter_cache: true +end