api without block syntax

This commit is contained in:
Dylan Knutson
2025-07-13 03:36:29 +00:00
parent af97998393
commit e1c1e03e74
4 changed files with 376 additions and 96 deletions

View File

@@ -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.