fix model saving
This commit is contained in:
@@ -8,6 +8,7 @@ require "active_support"
|
||||
require "active_support/concern"
|
||||
require "active_model/attribute_set"
|
||||
require_relative "aux_table/auto_join_queries"
|
||||
require_relative "aux_table/migration_extensions"
|
||||
|
||||
module ActiveRecord
|
||||
module AuxTable
|
||||
@@ -87,7 +88,7 @@ module ActiveRecord
|
||||
aux_table_configurations[table_name] = config =
|
||||
generate_aux_model_class(table_name)
|
||||
setup_schema_loading_hook(table_name)
|
||||
setup_auto_join_queries(table_name)
|
||||
setup_auto_join_queries(config)
|
||||
|
||||
config
|
||||
end
|
||||
@@ -160,6 +161,19 @@ module ActiveRecord
|
||||
aux_config.define_aux_attribute_delegate(self, "#{column_name}=")
|
||||
end
|
||||
|
||||
%i[save save!].each do |method_name|
|
||||
save_method = self.instance_method(method_name)
|
||||
self.define_method(method_name) do |*args|
|
||||
result = save_method.bind(self).call(*args)
|
||||
result &&=
|
||||
self
|
||||
.association(aux_config.aux_association_name)
|
||||
.target
|
||||
.send(method_name)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
%i[_read_attribute read_attribute].each do |method_name|
|
||||
# override _read_attribute to delegate auxiliary columns to the auxiliary table
|
||||
read_attribute_method = self.instance_method(method_name)
|
||||
@@ -240,19 +254,17 @@ module ActiveRecord
|
||||
sig { params(table_name: Symbol).returns(AuxTableConfig) }
|
||||
def generate_aux_model_class(table_name)
|
||||
# Generate class name (e.g., :car_aux => "CarAux")
|
||||
class_name = table_name.to_s.camelize
|
||||
aux_class_name = table_name.to_s.camelize
|
||||
aux_association_name = table_name.to_s.singularize.to_sym
|
||||
|
||||
# Ensure the class name doesn't conflict with existing constants
|
||||
if Object.const_defined?(class_name)
|
||||
Kernel.raise ArgumentError, "Class #{class_name} already exists"
|
||||
if Object.const_defined?(aux_class_name)
|
||||
Kernel.raise ArgumentError, "Class #{aux_class_name} already exists"
|
||||
end
|
||||
|
||||
# Get the base class name for the foreign key (e.g., Vehicle -> vehicle_id)
|
||||
# In STI, all subclasses share the same table, so we need the base class
|
||||
base_class = self.base_class
|
||||
base_class_name = base_class.name.underscore
|
||||
foreign_key = "#{base_class_name}_id".to_sym
|
||||
foreign_key = "base_table_id".to_sym
|
||||
|
||||
# Get the current class for the association
|
||||
main_class = self
|
||||
@@ -260,10 +272,11 @@ module ActiveRecord
|
||||
primary_key = :id
|
||||
|
||||
# Create the auxiliary model class
|
||||
model_class =
|
||||
aux_class =
|
||||
Class.new(ActiveRecord::Base) do
|
||||
# Set the table name
|
||||
self.table_name = table_name.to_s
|
||||
self.primary_key = "base_table_id"
|
||||
|
||||
# Define the association back to the specific STI subclass
|
||||
# Foreign key points to base STI table (e.g., vehicle_id)
|
||||
@@ -280,19 +293,26 @@ module ActiveRecord
|
||||
# set up has_one association to the auxiliary table
|
||||
self.has_one(
|
||||
aux_association_name,
|
||||
class_name: class_name,
|
||||
class_name: aux_class_name,
|
||||
foreign_key:,
|
||||
primary_key:,
|
||||
inverse_of: main_association_name,
|
||||
autosave: true
|
||||
inverse_of: main_association_name
|
||||
# autosave: true
|
||||
)
|
||||
|
||||
after_create do
|
||||
aux_model = association(aux_association_name).target
|
||||
aux_model.base_table_id = self.id
|
||||
aux_model.save!
|
||||
true
|
||||
end
|
||||
|
||||
# Set the constant to make the class accessible
|
||||
Object.const_set(class_name, model_class)
|
||||
Object.const_set(aux_class_name, aux_class)
|
||||
|
||||
AuxTableConfig.new(
|
||||
table_name:,
|
||||
model_class:,
|
||||
model_class: aux_class,
|
||||
aux_association_name:,
|
||||
main_association_name:,
|
||||
foreign_key:,
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
module ActiveRecord
|
||||
module AuxTable
|
||||
module AutoJoinQueries
|
||||
def setup_auto_join_queries(aux_table_name)
|
||||
association_name = aux_table_name.to_s.singularize.to_sym
|
||||
def setup_auto_join_queries(aux_config)
|
||||
ActiveRecord::AuxTable::AutoJoinQueries.setup_query_extensions!(
|
||||
self,
|
||||
association_name
|
||||
aux_config,
|
||||
with_bind_attribute: false
|
||||
)
|
||||
self
|
||||
end
|
||||
|
||||
# Get all aux column names for this model
|
||||
@@ -19,12 +18,13 @@ module ActiveRecord
|
||||
return [] unless config&.model_class
|
||||
|
||||
config.model_class.column_names.reject do |col|
|
||||
%w[id created_at updated_at].include?(col) || col.end_with?("_id")
|
||||
%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, aux_columns)
|
||||
def split_conditions(conditions, association_name)
|
||||
aux_columns = self.get_aux_column_names(association_name)
|
||||
main_conditions = {}
|
||||
aux_conditions = {}
|
||||
|
||||
@@ -39,14 +39,19 @@ module ActiveRecord
|
||||
[main_conditions, aux_conditions]
|
||||
end
|
||||
|
||||
def self.setup_query_extensions!(on, association_name)
|
||||
def self.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)
|
||||
self.apply_split_conditions!(relation, args)
|
||||
ActiveRecord::AuxTable::AutoJoinQueries.setup_query_extensions!(
|
||||
relation,
|
||||
association_name
|
||||
aux_config
|
||||
)
|
||||
relation
|
||||
else
|
||||
@@ -54,6 +59,35 @@ module ActiveRecord
|
||||
end
|
||||
end
|
||||
|
||||
unscoped_method = on.method(:unscoped)
|
||||
on.define_singleton_method(:unscoped) do
|
||||
relation = unscoped_method.call
|
||||
ActiveRecord::AuxTable::AutoJoinQueries.setup_query_extensions!(
|
||||
relation,
|
||||
aux_config
|
||||
)
|
||||
relation
|
||||
end
|
||||
|
||||
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)
|
||||
else
|
||||
bind_attribute_method.call(name, value, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on.define_singleton_method(:find_by) do |*args|
|
||||
relation = self.eager_load(association_name)
|
||||
self.apply_split_conditions!(relation, args)
|
||||
@@ -62,9 +96,8 @@ module ActiveRecord
|
||||
|
||||
on.define_singleton_method(:apply_split_conditions!) do |relation, args|
|
||||
conditions = args.first
|
||||
aux_columns = self.get_aux_column_names(association_name)
|
||||
main_conditions, aux_conditions =
|
||||
self.split_conditions(conditions, aux_columns)
|
||||
self.split_conditions(conditions, association_name)
|
||||
relation.where!(main_conditions) if main_conditions.any?
|
||||
if aux_conditions.any?
|
||||
relation.where!(association_name => aux_conditions)
|
||||
@@ -74,6 +107,21 @@ module ActiveRecord
|
||||
on.define_singleton_method(:find) do |*args|
|
||||
self.eager_load(association_name).find(*args)
|
||||
end
|
||||
|
||||
on.define_singleton_method(:exists?) do |*args|
|
||||
conditions = args.first || {}
|
||||
main_conditions, aux_conditions =
|
||||
self.split_conditions(conditions, association_name)
|
||||
puts "checking with conditions: #{main_conditions} / #{aux_conditions}"
|
||||
|
||||
relation = self.select("1").joins(association_name)
|
||||
relation.where!(main_conditions) if main_conditions.any?
|
||||
if aux_conditions.any?
|
||||
relation.where!(association_name => aux_conditions)
|
||||
end
|
||||
|
||||
relation.first.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
26
lib/active_record/aux_table/migration_extensions.rb
Normal file
26
lib/active_record/aux_table/migration_extensions.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ActiveRecord
|
||||
module AuxTable
|
||||
module MigrationExtensions
|
||||
def create_aux_table(base_table, name, **options)
|
||||
aux_table_name = "#{base_table}_#{name}_aux"
|
||||
create_table(aux_table_name, id: false, **options) do |t|
|
||||
t.references :base_table,
|
||||
primary_key: true,
|
||||
null: false,
|
||||
foreign_key: {
|
||||
to_table: base_table,
|
||||
validate: true
|
||||
}
|
||||
yield t
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ActiveRecord::Migration
|
||||
include ActiveRecord::AuxTable::MigrationExtensions
|
||||
end
|
||||
@@ -34,58 +34,31 @@ RSpec.describe ActiveRecord::AuxTable do
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_table :test_classes do |t|
|
||||
t.string :name
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Auxiliary tables for testing
|
||||
create_table :test_table do |t|
|
||||
t.references :test_class,
|
||||
null: false,
|
||||
foreign_key: {
|
||||
to_table: :test_classes
|
||||
}
|
||||
t.string :description # Changed from :name to avoid conflict
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_table :car_aux do |t|
|
||||
t.references :vehicle, null: false, foreign_key: { to_table: :vehicles }
|
||||
create_aux_table :vehicles, :car do |t|
|
||||
t.string :fuel_type
|
||||
t.decimal :engine_size, precision: 3, scale: 1
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_table :boat_aux do |t|
|
||||
t.references :vehicle, null: false, foreign_key: { to_table: :vehicles }
|
||||
create_aux_table :vehicles, :boat do |t|
|
||||
t.string :boat_type
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
# Define Car class after schema is set up
|
||||
class Vehicle < ActiveRecord::Base
|
||||
include ActiveRecord::AuxTable
|
||||
end
|
||||
|
||||
class Car < Vehicle
|
||||
aux_table(:car_aux)
|
||||
aux_table :vehicles_car_aux
|
||||
end
|
||||
|
||||
class Boat < Vehicle
|
||||
aux_table(:boat_aux)
|
||||
aux_table :vehicles_boat_aux
|
||||
end
|
||||
end
|
||||
|
||||
class TestClass < ActiveRecord::Base
|
||||
include ActiveRecord::AuxTable
|
||||
self.table_name = "test_classes"
|
||||
|
||||
aux_table(:test_table)
|
||||
end
|
||||
|
||||
class Vehicle < ActiveRecord::Base
|
||||
include ActiveRecord::AuxTable
|
||||
self.table_name = "vehicles"
|
||||
end
|
||||
|
||||
# Car class will be defined after schema setup
|
||||
|
||||
it "has a version number" do
|
||||
@@ -104,111 +77,7 @@ RSpec.describe ActiveRecord::AuxTable do
|
||||
expect(Vehicle.count).to eq(0)
|
||||
end
|
||||
|
||||
describe "module inclusion" do
|
||||
it "adds class methods to the including class" do
|
||||
expect(TestClass).to respond_to(:aux_table)
|
||||
expect(TestClass).to respond_to(:aux_table_configuration)
|
||||
expect(TestClass).to respond_to(:aux_table_configurations)
|
||||
end
|
||||
end
|
||||
|
||||
describe "aux_table DSL method" do
|
||||
describe "auxiliary model class generation" do
|
||||
it "generates auxiliary model class with proper configuration" do
|
||||
config = TestClass.aux_table_configuration(:test_table)
|
||||
|
||||
# Test model class generation
|
||||
expect(config.model_class).not_to be_nil
|
||||
expect(config.model_class.table_name).to eq("test_table")
|
||||
end
|
||||
|
||||
it "creates accessible constant for model class" do
|
||||
expect(Object.const_defined?("CarAux")).to be true
|
||||
expect(Object.const_get("CarAux")).to be_a(Class)
|
||||
expect(Object.const_get("CarAux")).to be < ActiveRecord::Base
|
||||
end
|
||||
|
||||
it "prevents duplicate class name conflicts" do
|
||||
# Define a constant to simulate conflict
|
||||
Object.const_set("ConflictAux", Class.new)
|
||||
|
||||
expect {
|
||||
TestClass.aux_table(:conflict_aux) { |t| t.string :name }
|
||||
}.to raise_error(ArgumentError, "Class ConflictAux already exists")
|
||||
end
|
||||
|
||||
it "defines belongs_to association on auxiliary model class" do
|
||||
config = Car.aux_table_configuration(:car_aux)
|
||||
|
||||
# Verify the association is defined
|
||||
association = config.model_class.reflect_on_association(:car)
|
||||
expect(association).to be_present
|
||||
expect(association.macro).to eq(:belongs_to)
|
||||
expect(association.class_name).to eq("Car")
|
||||
expect(association.foreign_key).to eq("vehicle_id")
|
||||
end
|
||||
|
||||
it "sets up correct associations for STI classes" do
|
||||
# Verify the association uses the base class table for the foreign key
|
||||
association = Car.reflect_on_association(:car_aux)
|
||||
expect(association).to be_present
|
||||
expect(association.macro).to eq(:has_one)
|
||||
expect(association.class_name).to eq("CarAux")
|
||||
expect(association.foreign_key).to eq("vehicle_id")
|
||||
end
|
||||
end
|
||||
|
||||
describe "validation" do
|
||||
it "raises TypeError for nil table name" do
|
||||
expect {
|
||||
TestClass.aux_table(nil) { |t| t.string :name }
|
||||
}.to raise_error(TypeError)
|
||||
end
|
||||
|
||||
it "raises TypeError for invalid table name type" do
|
||||
expect {
|
||||
TestClass.aux_table(123) { |t| t.string :name }
|
||||
}.to raise_error(TypeError)
|
||||
end
|
||||
|
||||
it "raises ArgumentError for duplicate table definitions" do
|
||||
expect {
|
||||
TestClass.aux_table(:test_table) { |t| t.string :description }
|
||||
}.to raise_error(
|
||||
ArgumentError,
|
||||
"Auxiliary table 'test_table' is already defined"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "database integration" do
|
||||
it "creates auxiliary model class with database operations and associations" do
|
||||
config = Car.aux_table_configuration(:car_aux)
|
||||
|
||||
# Verify the auxiliary model class is properly configured
|
||||
expect(config.model_class.column_names).to include(
|
||||
"fuel_type",
|
||||
"engine_size",
|
||||
"vehicle_id"
|
||||
)
|
||||
|
||||
# Test associations work correctly
|
||||
vehicle =
|
||||
Vehicle.create!(
|
||||
type: "Car",
|
||||
name: "Toyota Camry",
|
||||
fuel_type: "gasoline",
|
||||
engine_size: 2.5
|
||||
)
|
||||
|
||||
# Verify the association works
|
||||
expect(vehicle.car_aux.car).to eq(vehicle)
|
||||
expect(vehicle.car_aux.car.name).to eq("Toyota Camry")
|
||||
expect(vehicle.car_aux.fuel_type).to eq("gasoline")
|
||||
expect(vehicle.car_aux.engine_size).to eq(2.5)
|
||||
end
|
||||
|
||||
it "provides automatic attribute accessors for auxiliary table columns" do
|
||||
vehicle = Car.create!(name: "Honda Civic")
|
||||
|
||||
@@ -240,12 +109,12 @@ RSpec.describe ActiveRecord::AuxTable do
|
||||
end
|
||||
|
||||
it "allows saving the model with auxiliary columns" do
|
||||
vehicle = Car.create!(name: "Honda Civic")
|
||||
car = Car.create!(name: "Honda Civic")
|
||||
num_queries =
|
||||
count_queries do
|
||||
vehicle.fuel_type = "hybrid"
|
||||
vehicle.engine_size = 1.8
|
||||
vehicle.save!
|
||||
car.fuel_type = "hybrid"
|
||||
car.engine_size = 1.8
|
||||
car.save!
|
||||
end
|
||||
expect(num_queries).to eq(1)
|
||||
end
|
||||
@@ -254,14 +123,29 @@ RSpec.describe ActiveRecord::AuxTable do
|
||||
describe "query extensions" do
|
||||
before do
|
||||
# Create test data
|
||||
@car1 = Car.create!(name: "Toyota Prius", type: "Car")
|
||||
@car1.car_aux.update!(fuel_type: "hybrid", engine_size: 1.8)
|
||||
@car1 =
|
||||
Car.create!(
|
||||
name: "Toyota Prius",
|
||||
type: "Car",
|
||||
fuel_type: "hybrid",
|
||||
engine_size: 1.8
|
||||
)
|
||||
|
||||
@car2 = Car.create!(name: "Honda Civic", type: "Car")
|
||||
@car2.car_aux.update!(fuel_type: "gasoline", engine_size: 2.0)
|
||||
@car2 =
|
||||
Car.create!(
|
||||
name: "Honda Civic",
|
||||
type: "Car",
|
||||
fuel_type: "gasoline",
|
||||
engine_size: 2.0
|
||||
)
|
||||
|
||||
@car3 = Car.create!(name: "Tesla Model 3", type: "Car")
|
||||
@car3.car_aux.update!(fuel_type: "electric", engine_size: 0.0)
|
||||
@car3 =
|
||||
Car.create!(
|
||||
name: "Tesla Model 3",
|
||||
type: "Car",
|
||||
fuel_type: "electric",
|
||||
engine_size: 0.0
|
||||
)
|
||||
end
|
||||
|
||||
describe "find method with automatic joins" do
|
||||
|
||||
Reference in New Issue
Block a user