From 5f358abae6c29fa5f1cf2e9fa301c7c3860368aa Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Tue, 15 Jul 2025 03:50:34 +0000 Subject: [PATCH] checkpoint before breaking things --- demo_functionality.rb | 134 ++++++++++++++++++++++++ lib/aux_table/auto_join_queries.rb | 37 +++---- lib/has_aux_table.rb | 60 ++++++++--- sorbet/rbi/todo.rbi | 2 +- spec/active_record/aux_table_spec.rb | 151 +++++++++++++++++++++++---- 5 files changed, 324 insertions(+), 60 deletions(-) create mode 100755 demo_functionality.rb diff --git a/demo_functionality.rb b/demo_functionality.rb new file mode 100755 index 0000000..dcd6a7b --- /dev/null +++ b/demo_functionality.rb @@ -0,0 +1,134 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require_relative "lib/has_aux_table" +Bundler.require(:default, :development) + +ActiveRecord::Base.establish_connection( + adapter: "sqlite3", + database: ":memory:" +) +# ActiveRecord::Base.logger = Logger.new(STDOUT) + +ActiveRecord::Schema.define do + # Create base table and aux table in the same block + create_base_table :users do |t| + t.create_aux :fa do |t| + t.string :username, index: true + t.string :url_name, index: true + end + + t.timestamps + end + + # Change base table later to add an aux table + change_base_table :users do |t| + t.create_aux :e621 do |t| + t.integer :e621_id, index: true + t.string :username, index: true + end + end + + create_base_table :posts do |t| + t.timestamp :posted_at, index: true + t.timestamps + end + + # Directly create aux tables + create_aux_table :posts, :fa do |t| + t.references :creator, + foreign_key: { + to_table: :users_fa_aux, + primary_key: :base_table_id + } + t.integer :fa_id, index: true + t.string :title + t.string :species + end + + create_aux_table :posts, :e621 do |t| + t.integer :e621_id, index: true + t.string :md5, index: true + end + + create_table :user_post_fav_joins, id: false do |t| + t.references :user, null: false, foreign_key: { to_table: :users } + t.references :post, null: false, foreign_key: { to_table: :posts } + t.timestamps + end +end + +class UserPostFavJoin < ActiveRecord::Base + belongs_to :user + belongs_to :post +end + +class User < ActiveRecord::Base + include HasAuxTable + has_many :user_post_fav_joins, dependent: :destroy + has_many :favorite_posts, through: :user_post_fav_joins, source: :post + has_many :created_posts, class_name: "Post", inverse_of: :creator +end + +class FaUser < User + aux_table :fa + validates :url_name, presence: true + has_many :created_posts, class_name: "FaPost", inverse_of: :creator +end + +class E621User < User + aux_table :e621 + validates :username, presence: true + has_many :created_posts, class_name: "E621Post", inverse_of: :creator +end + +class Post < ActiveRecord::Base + include HasAuxTable + has_many :user_post_fav_joins, dependent: :destroy + has_many :favoriting_users, through: :user_post_fav_joins, source: :user + validates_presence_of :posted_at +end + +class FaPost < Post + aux_table :fa + belongs_to :creator, class_name: "FaUser", inverse_of: :created_posts + validates :fa_id, presence: true, uniqueness: true + validates :title, presence: true +end + +class E621Post < Post + aux_table :e621 + validates :e621_id, presence: true + belongs_to :creator, class_name: "E621User", inverse_of: :created_posts +end + +# ActiveRecord::Base.logger = Logger.new(STDOUT) + +fa_user = FaUser.create!(username: "Alice", url_name: "alice") + +# puts "Does the post exist? #{FaPost.exists?(fa_id: 1)}" +attrs = { + # creator_id: fa_user.id, + fa_id: 12_345, + title: "Test Post", + species: "Cat", + posted_at: 1.day.ago +} +fa_post = fa_user.created_posts.create!(attrs) +raise unless fa_post.persisted? +raise unless fa_post.creator == fa_user +raise unless fa_post.creator_id == fa_user.id + +fa_posts_all = FaPost.all.to_a +raise unless fa_posts_all.size == 1 +raise unless fa_posts_all.first.creator == fa_user +raise unless fa_posts_all.first.creator_id == fa_user.id + +# e621_user = E621User.create!(username: "bob", e621_id: 67_890) +# e621_post = +# E621Post.create!( +# e621_id: 102_938, +# md5: "DEADBEEF" * 4, +# posted_at: 2.weeks.ago +# ) +# e621_user.favorite_posts << e621_post diff --git a/lib/aux_table/auto_join_queries.rb b/lib/aux_table/auto_join_queries.rb index 17a7faa..94c27c8 100644 --- a/lib/aux_table/auto_join_queries.rb +++ b/lib/aux_table/auto_join_queries.rb @@ -5,24 +5,13 @@ 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) + def split_conditions(conditions, aux_config) main_conditions = {} aux_conditions = {} conditions.each do |key, value| - if aux_columns.include?(key.to_s) + if aux_config.is_aux_column?(key) aux_conditions[key] = value else main_conditions[key] = value @@ -41,6 +30,7 @@ module HasAuxTable 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) @@ -52,6 +42,11 @@ module HasAuxTable end end + all_method = on.method(:all) + on.define_singleton_method(:all) do + all_method.call.eager_load(association_name) + end + unscoped_method = on.method(:unscoped) on.define_singleton_method(:unscoped) do relation = unscoped_method.call @@ -62,16 +57,8 @@ module HasAuxTable 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) + if aux_config.is_aux_column?(name) + aux_config.aux_bind_attribute(name, value, &block) else bind_attribute_method.call(name, value, &block) end @@ -87,7 +74,7 @@ module HasAuxTable on.define_singleton_method(:apply_split_conditions!) do |relation, args| conditions = args.first main_conditions, aux_conditions = - self.split_conditions(conditions, association_name) + self.split_conditions(conditions, aux_config) relation.where!(main_conditions) if main_conditions.any? if aux_conditions.any? relation.where!(association_name => aux_conditions) @@ -101,7 +88,7 @@ module HasAuxTable on.define_singleton_method(:exists?) do |*args| conditions = args.first || {} main_conditions, aux_conditions = - self.split_conditions(conditions, association_name) + self.split_conditions(conditions, aux_config) puts "checking with conditions: #{main_conditions} / #{aux_conditions}" relation = self.select("1").joins(association_name) diff --git a/lib/has_aux_table.rb b/lib/has_aux_table.rb index d212cc9..9541227 100644 --- a/lib/has_aux_table.rb +++ b/lib/has_aux_table.rb @@ -39,6 +39,21 @@ module HasAuxTable aux_association.target ||= aux_association.build end + sig do + params(name: Symbol, value: T.untyped, block: T.proc.void).returns( + Arel::Nodes::Node + ) + end + def aux_bind_attribute(name, value, &block) + arel_attr = model_class.arel_table[name] + aux_bind = + model_class.predicate_builder.build_bind_attribute( + arel_attr.name, + value + ) + block.call(arel_attr, aux_bind) + end + sig do params( main_class: T.class_of(ActiveRecord::Base), @@ -63,6 +78,27 @@ module HasAuxTable aux_model = self.ensure_aux_target(main_model) aux_model.assign_attributes(aux_args) end + + sig { returns(T::Array[Symbol]) } + def aux_column_names + @aux_column_names ||= + model_class + .column_names + .reject do |col| + [ + self.foreign_key, + :base_table_id, + :created_at, + :updated_at + ].flatten.include?(col.to_sym) + end + .map(&:to_sym) + end + + sig { params(name: T.any(Symbol, String)).returns(T::Boolean) } + def is_aux_column?(name) + aux_column_names.include?(name.to_sym) + end end module ClassMethods @@ -173,15 +209,14 @@ module HasAuxTable end end - %i[_read_attribute read_attribute].each do |method_name| - # override _read_attribute to delegate auxiliary columns to the auxiliary table + %i[_read_attribute read_attribute write_attribute].each do |method_name| read_attribute_method = self.instance_method(method_name) - self.define_method(method_name) do |name| - if aux_columns_hash.include?(name.to_s) + self.define_method(method_name) do |name, *args, **kwargs| + if aux_config.is_aux_column?(name) target = aux_config.ensure_aux_target(self) - target.send(method_name, name) + target.send(method_name, name, *args, **kwargs) else - read_attribute_method.bind(self).call(name) + read_attribute_method.bind(self).call(name, *args, **kwargs) end end end @@ -189,8 +224,7 @@ module HasAuxTable 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) - + args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h) initialize_method.bind(self).call(main_args) aux_config.assign_aux_attributes(self, aux_args) end @@ -208,13 +242,6 @@ module HasAuxTable .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 @@ -293,7 +320,8 @@ module HasAuxTable class_name: aux_class_name, foreign_key:, primary_key:, - inverse_of: main_association_name + inverse_of: main_association_name, + dependent: :destroy # autosave: true ) diff --git a/sorbet/rbi/todo.rbi b/sorbet/rbi/todo.rbi index f1d0da3..8198917 100644 --- a/sorbet/rbi/todo.rbi +++ b/sorbet/rbi/todo.rbi @@ -9,7 +9,6 @@ module ::DateAndTime::Zones; end module ActiveModel::Error; end module ActiveRecord::ConnectionAdapters::DatabaseStatements; end module ActiveRecord::ConnectionAdapters::SchemaStatements; end -module ActiveRecord::ConnectionAdapters::TableDefinition; end module ActiveRecord::Rollback; end module ActiveRecord::StatementInvalid; end module ActiveSupport::ArrayInquirer; end @@ -18,3 +17,4 @@ module ActiveSupport::Notifications; end module ActiveSupport::SafeBuffer; end module ActiveSupport::StringInquirer; end module ActiveSupport::TimeZone; end +module HasAuxTable::AuxTableConfig::Arel::Nodes::Node; end diff --git a/spec/active_record/aux_table_spec.rb b/spec/active_record/aux_table_spec.rb index 7ec65eb..149aad2 100644 --- a/spec/active_record/aux_table_spec.rb +++ b/spec/active_record/aux_table_spec.rb @@ -41,6 +41,38 @@ RSpec.describe HasAuxTable do t.string :boat_type end end + + create_base_table :people do |t| + t.string :name + t.timestamps + + t.create_aux :driver do |t| + t.references :car, + null: false, + 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 end class Vehicle < ActiveRecord::Base @@ -49,10 +81,33 @@ RSpec.describe HasAuxTable do class Car < Vehicle aux_table :car + has_many :drivers, inverse_of: :car end class Boat < Vehicle aux_table :boat + has_many :passengers, inverse_of: :boat + belongs_to :captain, inverse_of: :boat + end + + class Person < ActiveRecord::Base + include HasAuxTable + self.table_name = "people" + end + + class Driver < Person + aux_table :driver + belongs_to :car, inverse_of: :drivers + end + + class Captain < Person + aux_table :captain + has_one :boat, inverse_of: :captain + end + + class Passenger < Person + aux_table :passenger + belongs_to :boat, inverse_of: :passengers end end @@ -558,36 +613,96 @@ RSpec.describe HasAuxTable do end end - describe "#reload" do + describe "methods that depend on relation" do before(:each) do @car = Car.create!(name: "Toyota Prius", fuel_type: "hybrid", engine_size: 1.5) end - it "discards changes to aux attributes when reloading the model" do - @car.fuel_type = "gasoline" - @car.reload - expect(@car.fuel_type).to eq("hybrid") + describe "destroy" do + it "destroys the main record" do + expect { @car.destroy }.to change { Car.count }.by(-1) + end + + it "destroys the aux record" do + expect { @car.destroy }.to change { + Object.const_get(:VehiclesCarAux).count + }.by(-1) + end end - it "discards changes to main attributes when reloading the model" do - @car.name = "Honda Civic" - @car.reload - expect(@car.name).to eq("Toyota Prius") + describe "nested associations" do + it "can create a driver through the association", skip: true do + driver = @car.drivers.create!(name: "John Doe") + expect(driver.car).to eq(@car) + expect(driver.car_id).to eq(@car.id) + expect(driver.car.fuel_type).to eq("hybrid") + expect(driver.car.engine_size).to eq(1.5) + end + + it "can create a driver directly", skip: true do + driver = Driver.create!(car: @car, name: "John Doe") + expect(driver.car).to eq(@car) + expect(driver.car_id).to eq(@car.id) + expect(driver.car.fuel_type).to eq("hybrid") + expect(driver.car.engine_size).to eq(1.5) + end + + it "can be accessed through the association", skip: true do + driver = @car.drivers.create!(name: "John Doe") + expect(@car.drivers).to eq([driver]) + end + + it "can be destroyed through the association", skip: true do + driver = @car.drivers.build(name: "John Doe") + expect { driver.destroy }.to change { @car.drivers.count }.by(-1) + end end - it "can be saved after reloading" do - @car.reload - @car.name = "Honda Civic" - @car.save! + describe "#reload" do + it "discards changes to aux attributes when reloading the model" do + @car.fuel_type = "gasoline" + @car.reload + expect(@car.fuel_type).to eq("hybrid") + end - car = Car.find(@car.id) - expect(car.name).to eq("Honda Civic") + it "discards changes to main attributes when reloading the model" do + @car.name = "Honda Civic" + @car.reload + expect(@car.name).to eq("Toyota Prius") + end + + it "can be saved after reloading" do + @car.reload + @car.name = "Honda Civic" + @car.save! + + car = Car.find(@car.id) + expect(car.name).to eq("Honda Civic") + end + + it "reloads with one query" do + num_queries = count_queries { @car.reload } + expect(num_queries).to eq(1) + end end - it "reloads with one query" do - num_queries = count_queries { @car.reload } - expect(num_queries).to eq(1) + describe "#exists?" do + it "works when present with base table attributes" do + expect(Car.exists?(id: @car.id)).to be_truthy + end + + it "works when missing with with base table attributes" do + expect(Car.exists?(id: 9999)).to be_falsey + end + + it "works when present with aux table attributes" do + expect(Car.exists?(fuel_type: "hybrid")).to be_truthy + end + + it "works when missing with aux table attributes" do + expect(Car.exists?(fuel_type: "diesel")).to be_falsey + end end end end