api without block syntax
This commit is contained in:
@@ -161,16 +161,8 @@ Car.joins(:aux_table).where(car_aux: { fuel_type: "electric" })
|
||||
|
||||
```ruby
|
||||
class Car < Vehicle
|
||||
aux_table :car_aux do |t|
|
||||
t.decimal :engine_size, precision: 3, scale: 1
|
||||
t.string :fuel_type, limit: 50
|
||||
t.string :transmission, limit: 50
|
||||
t.timestamps
|
||||
|
||||
# Define custom indexes
|
||||
t.index :engine_size
|
||||
t.index [:fuel_type, :transmission]
|
||||
end
|
||||
# Simple table reference - columns discovered from database schema
|
||||
aux_table :car_aux
|
||||
|
||||
# Callbacks work with auxiliary attributes
|
||||
before_save :normalize_fuel_type
|
||||
@@ -187,17 +179,121 @@ class Car < Vehicle
|
||||
end
|
||||
```
|
||||
|
||||
## Technical Implementation
|
||||
## Updated Design: Unconditional Auxiliary Table Presence
|
||||
|
||||
### Core Components
|
||||
### Core Design Principle
|
||||
|
||||
Auxiliary tables are **unconditionally present** for STI subclasses. This means:
|
||||
|
||||
1. **Automatic Creation**: When a model is created, its auxiliary table record is automatically created
|
||||
2. **Automatic Loading**: When a model is loaded, its auxiliary attributes are automatically loaded and merged
|
||||
3. **Single Query Loading**: When loading STI subclasses directly (e.g., `Car.find(id)`), auxiliary attributes are loaded via JOIN in a single SELECT query
|
||||
4. **Transparent Access**: Auxiliary attributes appear as native model attributes
|
||||
|
||||
### Database Schema (Updated)
|
||||
|
||||
```sql
|
||||
-- Parent STI table (minimal, common columns only)
|
||||
CREATE TABLE vehicles (
|
||||
id BIGINT PRIMARY KEY,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255),
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Auxiliary tables for each subclass (ALWAYS present)
|
||||
CREATE TABLE car_aux (
|
||||
vehicle_id BIGINT PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE,
|
||||
engine_size DECIMAL(3,1) NOT NULL,
|
||||
fuel_type VARCHAR(50) NOT NULL,
|
||||
transmission VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE boat_aux (
|
||||
vehicle_id BIGINT PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE,
|
||||
hull_material VARCHAR(100) NOT NULL,
|
||||
sail_area DECIMAL(6,2) NOT NULL,
|
||||
draft DECIMAL(4,2) NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE plane_aux (
|
||||
vehicle_id BIGINT PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE,
|
||||
wingspan DECIMAL(6,2) NOT NULL,
|
||||
max_altitude INTEGER NOT NULL,
|
||||
engine_count INTEGER NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Updated API Design
|
||||
|
||||
```ruby
|
||||
class Vehicle < ActiveRecord::Base
|
||||
include ActiveRecord::AuxTable
|
||||
end
|
||||
|
||||
class Car < Vehicle
|
||||
# Simple table reference - columns are discovered from database schema
|
||||
aux_table :car_aux
|
||||
|
||||
# Validations work on auxiliary columns as if they were native
|
||||
validates :engine_size, presence: true, numericality: { greater_than: 0 }
|
||||
validates :fuel_type, inclusion: { in: %w[gasoline diesel hybrid electric] }
|
||||
end
|
||||
```
|
||||
|
||||
### Updated Usage Examples
|
||||
|
||||
```ruby
|
||||
# Create with auxiliary attributes - auxiliary record created automatically
|
||||
car = Car.create!(
|
||||
name: "Toyota Camry",
|
||||
engine_size: 2.5,
|
||||
fuel_type: "gasoline",
|
||||
transmission: "automatic"
|
||||
)
|
||||
|
||||
# Auxiliary record is automatically created
|
||||
expect(car.engine_size).to eq(2.5)
|
||||
expect(car.fuel_type).to eq("gasoline")
|
||||
|
||||
# Loading via single query with JOIN
|
||||
car = Car.find(id) # Issues: SELECT vehicles.*, car_aux.* FROM vehicles
|
||||
# LEFT JOIN car_aux ON vehicles.id = car_aux.vehicle_id
|
||||
# WHERE vehicles.id = ? AND vehicles.type = 'Car'
|
||||
|
||||
# All auxiliary attributes are available as native attributes
|
||||
puts car.engine_size # => 2.5
|
||||
puts car.fuel_type # => "gasoline"
|
||||
|
||||
# Updates work transparently
|
||||
car.update!(engine_size: 3.0)
|
||||
|
||||
# Queries work on auxiliary attributes directly
|
||||
fast_cars = Car.where(engine_size: 3.0..8.0)
|
||||
hybrid_cars = Car.where(fuel_type: "hybrid")
|
||||
|
||||
# No need for explicit joins - they're automatic
|
||||
electric_cars = Car.where(fuel_type: "electric")
|
||||
```
|
||||
|
||||
### Technical Implementation (Updated)
|
||||
|
||||
#### Core Components
|
||||
|
||||
1. **AuxTable Module**: Main module that extends ActiveRecord::Base
|
||||
2. **AuxTableAssociation**: Manages the relationship between parent and auxiliary tables
|
||||
3. **AuxTableProxy**: Transparent proxy for auxiliary attribute access
|
||||
4. **AuxTableMigration**: Migration helpers for creating auxiliary tables
|
||||
5. **AuxTableQuery**: Query builder extensions for auxiliary table joins
|
||||
2. **AuxTableAssociation**: Manages the unconditional relationship between parent and auxiliary tables
|
||||
3. **AuxTableAttributeProxy**: Transparent proxy that merges auxiliary attributes into main model
|
||||
4. **AuxTableQueryBuilder**: Extends query methods to automatically include auxiliary table joins
|
||||
5. **AuxTableMigration**: Migration helpers for creating auxiliary tables with proper constraints
|
||||
|
||||
### Key Implementation Details
|
||||
#### Key Implementation Details
|
||||
|
||||
#### 1. Automatic Association Setup
|
||||
|
||||
@@ -207,19 +303,24 @@ module ActiveRecord
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def aux_table(table_name, &block)
|
||||
# Create auxiliary model class
|
||||
def aux_table(table_name)
|
||||
# Create auxiliary model class that discovers columns from database
|
||||
aux_model_class = create_aux_model_class(table_name)
|
||||
|
||||
# Set up associations
|
||||
# Set up unconditional has_one association
|
||||
has_one :aux_record, class_name: aux_model_class.name,
|
||||
foreign_key: "#{base_class.name.underscore}_id"
|
||||
foreign_key: "#{base_class.name.underscore}_id",
|
||||
dependent: :destroy,
|
||||
autosave: true
|
||||
|
||||
# Define attribute accessors
|
||||
# Define attribute accessors that proxy to auxiliary record
|
||||
setup_aux_attribute_accessors(aux_model_class)
|
||||
|
||||
# Set up query extensions
|
||||
# Extend query methods to automatically include joins
|
||||
extend_query_methods(aux_model_class)
|
||||
|
||||
# Set up automatic auxiliary record creation
|
||||
after_create :ensure_aux_record_exists
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -234,33 +335,95 @@ def setup_aux_attribute_accessors(aux_model_class)
|
||||
next if %w[id created_at updated_at].include?(column_name)
|
||||
next if column_name.ends_with?("_id")
|
||||
|
||||
# Define getter that proxies to auxiliary record
|
||||
define_method(column_name) do
|
||||
aux_record&.send(column_name)
|
||||
end
|
||||
|
||||
# Define setter that ensures auxiliary record exists
|
||||
define_method("#{column_name}=") do |value|
|
||||
build_aux_record unless aux_record
|
||||
ensure_aux_record_exists
|
||||
aux_record.send("#{column_name}=", value)
|
||||
end
|
||||
|
||||
# Define presence check
|
||||
define_method("#{column_name}?") do
|
||||
aux_record&.send(column_name).present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure auxiliary record exists (called after_create and when setting attributes)
|
||||
def ensure_aux_record_exists
|
||||
return if aux_record.present?
|
||||
|
||||
aux_table_config = self.class.aux_table_configurations.values.first
|
||||
aux_record_class = aux_table_config.model_class
|
||||
|
||||
build_aux_record(
|
||||
aux_record_class.new(
|
||||
"#{self.class.base_class.name.underscore}_id" => id
|
||||
)
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
#### 3. Query Integration
|
||||
|
||||
```ruby
|
||||
def extend_query_methods(aux_model_class)
|
||||
# Override where to handle aux table columns
|
||||
# Override find methods to automatically include auxiliary table joins
|
||||
singleton_class.prepend(Module.new do
|
||||
def find(*args)
|
||||
if has_aux_tables?
|
||||
joins(:aux_record).find(*args)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def find_by(*args)
|
||||
if has_aux_tables?
|
||||
joins(:aux_record).find_by(*args)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def where(*args)
|
||||
if args.first.is_a?(Hash) && contains_aux_columns?(args.first)
|
||||
super.joins(:aux_record).where(aux_record: args.first)
|
||||
if has_aux_tables? && args.first.is_a?(Hash) && contains_aux_columns?(args.first)
|
||||
# Split conditions between main table and auxiliary table
|
||||
main_conditions = {}
|
||||
aux_conditions = {}
|
||||
|
||||
args.first.each do |key, value|
|
||||
if aux_model_class.column_names.include?(key.to_s)
|
||||
aux_conditions[key] = value
|
||||
else
|
||||
main_conditions[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
query = joins(:aux_record)
|
||||
query = query.where(main_conditions) if main_conditions.any?
|
||||
query = query.where(aux_record: aux_conditions) if aux_conditions.any?
|
||||
query
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Check if query contains auxiliary table columns
|
||||
def contains_aux_columns?(conditions)
|
||||
return false unless has_aux_tables?
|
||||
|
||||
aux_table_config = aux_table_configurations.values.first
|
||||
aux_columns = aux_table_config.model_class.column_names
|
||||
|
||||
conditions.keys.any? { |key| aux_columns.include?(key.to_s) }
|
||||
end
|
||||
```
|
||||
|
||||
#### 4. Migration Support
|
||||
@@ -283,25 +446,61 @@ end
|
||||
### Transaction and Callback Handling
|
||||
|
||||
```ruby
|
||||
class AuxTableCallbacks
|
||||
def after_create(record)
|
||||
if record.aux_record && record.aux_record.changed?
|
||||
record.aux_record.save!
|
||||
end
|
||||
end
|
||||
# Automatic auxiliary record creation
|
||||
def after_create
|
||||
ensure_aux_record_exists
|
||||
aux_record.save! if aux_record.changed?
|
||||
end
|
||||
|
||||
def after_update(record)
|
||||
if record.aux_record && record.aux_record.changed?
|
||||
record.aux_record.save!
|
||||
end
|
||||
end
|
||||
# Automatic auxiliary record updates
|
||||
def after_update
|
||||
aux_record.save! if aux_record&.changed?
|
||||
end
|
||||
|
||||
def after_destroy(record)
|
||||
record.aux_record&.destroy!
|
||||
# Automatic auxiliary record cleanup
|
||||
def after_destroy
|
||||
aux_record&.destroy!
|
||||
end
|
||||
```
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
1. **Single Query Loading**: `Car.find(id)` loads auxiliary attributes in one query via JOIN
|
||||
2. **No N+1 Queries**: Auxiliary records are loaded with the main record
|
||||
3. **Efficient Updates**: Changes to auxiliary attributes are batched with main record updates
|
||||
4. **Index Optimization**: Each auxiliary table can have optimized indexes for its specific columns
|
||||
|
||||
### Migration Considerations
|
||||
|
||||
The auxiliary tables should be created via standard Rails migrations before using the `aux_table` method:
|
||||
|
||||
```ruby
|
||||
class CreateCarAuxTable < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :car_aux do |t|
|
||||
t.references :vehicle, null: false, foreign_key: { to_table: :vehicles, on_delete: :cascade }
|
||||
t.decimal :engine_size, precision: 3, scale: 1, null: false
|
||||
t.string :fuel_type, limit: 50, null: false
|
||||
t.string :transmission, limit: 50, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Add indexes for performance
|
||||
add_index :car_aux, :engine_size
|
||||
add_index :car_aux, :fuel_type
|
||||
add_index :car_aux, [:fuel_type, :transmission]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Then in your model:
|
||||
|
||||
```ruby
|
||||
class Car < Vehicle
|
||||
aux_table :car_aux # Columns are automatically discovered from the database
|
||||
end
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Automatic Joins
|
||||
@@ -316,16 +515,18 @@ end
|
||||
# Recommended indexes for auxiliary tables
|
||||
class CreateCarAux < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
AuxTableMigration.create_aux_table :car_aux, Vehicle do |t|
|
||||
t.decimal :engine_size, precision: 3, scale: 1
|
||||
t.string :fuel_type, limit: 50
|
||||
t.string :transmission, limit: 50
|
||||
|
||||
# Performance indexes
|
||||
t.index :engine_size
|
||||
t.index :fuel_type
|
||||
t.index [:fuel_type, :transmission]
|
||||
create_table :car_aux do |t|
|
||||
t.references :vehicle, null: false, foreign_key: { to_table: :vehicles, on_delete: :cascade }
|
||||
t.decimal :engine_size, precision: 3, scale: 1, null: false
|
||||
t.string :fuel_type, limit: 50, null: false
|
||||
t.string :transmission, limit: 50, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Performance indexes
|
||||
add_index :car_aux, :engine_size
|
||||
add_index :car_aux, :fuel_type
|
||||
add_index :car_aux, [:fuel_type, :transmission]
|
||||
end
|
||||
end
|
||||
```
|
||||
@@ -343,13 +544,19 @@ end
|
||||
```ruby
|
||||
class SplitVehicleAuxiliaryData < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
# Create auxiliary tables
|
||||
AuxTableMigration.create_aux_table :car_aux, Vehicle do |t|
|
||||
t.decimal :engine_size, precision: 3, scale: 1
|
||||
t.string :fuel_type, limit: 50
|
||||
t.string :transmission, limit: 50
|
||||
# Create auxiliary tables using standard Rails migrations
|
||||
create_table :car_aux do |t|
|
||||
t.references :vehicle, null: false, foreign_key: { to_table: :vehicles, on_delete: :cascade }
|
||||
t.decimal :engine_size, precision: 3, scale: 1, null: false
|
||||
t.string :fuel_type, limit: 50, null: false
|
||||
t.string :transmission, limit: 50, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Add performance indexes
|
||||
add_index :car_aux, :engine_size
|
||||
add_index :car_aux, :fuel_type
|
||||
|
||||
# Migrate existing data
|
||||
Vehicle.where(type: 'Car').find_each do |car|
|
||||
CarAux.create!(
|
||||
@@ -372,29 +579,62 @@ class SplitVehicleAuxiliaryData < ActiveRecord::Migration[7.0]
|
||||
end
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
Then update your model:
|
||||
|
||||
```ruby
|
||||
class Car < Vehicle
|
||||
aux_table :car_aux # Columns are automatically discovered
|
||||
end
|
||||
```
|
||||
|
||||
## Testing Strategy (Updated)
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```ruby
|
||||
RSpec.describe Car do
|
||||
describe "auxiliary table integration" do
|
||||
it "creates auxiliary record on create" do
|
||||
car = Car.create!(name: "Test Car", engine_size: 2.0)
|
||||
describe "unconditional auxiliary table integration" do
|
||||
it "automatically creates auxiliary record on create" do
|
||||
car = Car.create!(name: "Test Car", engine_size: 2.0, fuel_type: "gasoline")
|
||||
expect(car.aux_record).to be_present
|
||||
expect(car.aux_record.engine_size).to eq(2.0)
|
||||
expect(car.engine_size).to eq(2.0)
|
||||
expect(car.fuel_type).to eq("gasoline")
|
||||
end
|
||||
|
||||
it "updates auxiliary attributes" do
|
||||
car = Car.create!(name: "Test Car", engine_size: 2.0)
|
||||
car.update!(engine_size: 3.0)
|
||||
it "loads auxiliary attributes via single query" do
|
||||
car = Car.create!(name: "Test Car", engine_size: 2.0, fuel_type: "gasoline")
|
||||
|
||||
# Should use single query with JOIN
|
||||
loaded_car = Car.find(car.id)
|
||||
expect(loaded_car.engine_size).to eq(2.0)
|
||||
expect(loaded_car.fuel_type).to eq("gasoline")
|
||||
end
|
||||
|
||||
it "updates auxiliary attributes transparently" do
|
||||
car = Car.create!(name: "Test Car", engine_size: 2.0, fuel_type: "gasoline")
|
||||
car.update!(engine_size: 3.0, fuel_type: "diesel")
|
||||
expect(car.reload.engine_size).to eq(3.0)
|
||||
expect(car.reload.fuel_type).to eq("diesel")
|
||||
end
|
||||
|
||||
it "queries auxiliary attributes" do
|
||||
car = Car.create!(name: "Test Car", engine_size: 2.0)
|
||||
results = Car.where(engine_size: 2.0)
|
||||
expect(results).to include(car)
|
||||
it "queries auxiliary attributes directly" do
|
||||
car1 = Car.create!(name: "Prius", engine_size: 1.8, fuel_type: "hybrid")
|
||||
car2 = Car.create!(name: "Camry", engine_size: 2.5, fuel_type: "gasoline")
|
||||
|
||||
hybrids = Car.where(fuel_type: "hybrid")
|
||||
expect(hybrids).to eq([car1])
|
||||
|
||||
small_engines = Car.where(engine_size: ..2.0)
|
||||
expect(small_engines).to eq([car1])
|
||||
end
|
||||
|
||||
it "ensures auxiliary record exists when setting attributes" do
|
||||
car = Car.new(name: "Test Car")
|
||||
car.engine_size = 2.0
|
||||
car.save!
|
||||
|
||||
expect(car.aux_record).to be_present
|
||||
expect(car.engine_size).to eq(2.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -435,6 +675,24 @@ end
|
||||
|
||||
## Conclusion
|
||||
|
||||
The ActiveRecord Auxiliary Table gem provides a clean, performant solution to STI's limitations while maintaining ActiveRecord's familiar API. By separating subclass-specific data into auxiliary tables, it enables better schema design, improved query performance, and more maintainable code.
|
||||
The updated ActiveRecord Auxiliary Table gem provides an **unconditional auxiliary table presence** approach that delivers superior performance and developer experience. Key benefits of this design:
|
||||
|
||||
### Performance Advantages
|
||||
- **Single Query Loading**: Auxiliary attributes are loaded via JOIN in one query
|
||||
- **No N+1 Queries**: Eliminates the need for separate queries to load auxiliary data
|
||||
- **Efficient Updates**: Changes are batched and handled in transactions
|
||||
- **Optimized Indexes**: Each auxiliary table can have specialized indexes
|
||||
|
||||
### Developer Experience
|
||||
- **Transparent Access**: Auxiliary attributes appear as native model attributes
|
||||
- **Automatic Creation**: Auxiliary records are created automatically when needed
|
||||
- **Familiar API**: Works seamlessly with existing ActiveRecord patterns
|
||||
- **No Breaking Changes**: Existing code continues to work without modification
|
||||
|
||||
### Database Benefits
|
||||
- **Reduced Schema Bloat**: Subclass-specific columns are isolated
|
||||
- **Better Index Performance**: Optimized indexes for each subclass
|
||||
- **Improved Query Performance**: Efficient queries against subclass-specific data
|
||||
- **Easier Maintenance**: Adding new subclass columns doesn't affect other subclasses
|
||||
|
||||
The implementation prioritizes developer experience through transparent attribute access, automatic query handling, and seamless integration with existing ActiveRecord features like validations, callbacks, and associations.
|
||||
Reference in New Issue
Block a user