Simulates multiple-table-inheritance (MTI) for ActiveRecord models. By default, ActiveRecord only supports single-table inheritance (STI). MTI gives you the benefits of STI but without having to place dozens of empty fields into a single table.
Take a traditional e-commerce application for example:
A product has common attributes (name, price, image ...),
while each type of product has its own attributes:
for example a pen has color, a book has author and publisher and so on.
With multiple-table-inheritance you can have a products table with common columns and
a separate table for each product type, i.e. a pens table with color column.
- Ruby >=
2.7 - ActiveSupport >=
6.0( supportsmain/edge branch ) - ActiveRecord >=
6.0( supportsmain/edge branch )
Add this line to your application's Gemfile:
gem 'active_record-acts_as'
And then execute:
$ bundle
Or install it yourself as:
$ gem install active_record-acts_as
Back to example above, all you have to do is to mark Product as actable and all product type models as acts_as :product:
class Product < ActiveRecord::Base
actable
belongs_to :store
validates_presence_of :name, :price
def info
"#{name} $#{price}"
end
end
class Pen < ActiveRecord::Base
acts_as :product
end
class Book < ActiveRecord::Base
# In case you don't wish to validate
# this model against Product
acts_as :product, validates_actable: false
end
class Store < ActiveRecord::Base
has_many :products
endand add foreign key and type columns to products table as in a polymorphic relation. You may prefer using a migration:
change_table :products do |t|
t.integer :actable_id
t.string :actable_type
endor use shortcut actable
change_table :products do |t|
t.actable
endMake sure that column names do not match on parent and subclass tables,
that will make SQL statements ambiguous and invalid!
Specially DO NOT use timestamps on subclasses, if you need them define them
on parent table and they will be touched after submodel updates (You can use the option touch: false to skip this behaviour).
Now Pen and Book acts as Product, i.e. they inherit Products attributes,
methods and validations. Now you can do things like these:
Pen.create name: 'Penie!', price: 0.8, color: 'red'
# => #<Pen id: 1, color: "red">
Pen.where price: 0.8
# => [#<Pen id: 1, color: "red">]
# You can seamlessly query Product attributes
pen = Pen.where(name: 'new pen', color: 'black').first_or_initialize
# => #<Pen id: nil, color: "black">
pen.name
# => "new pen"
# You can also call `exists?` using Product attributes:
Pen.exists?(name: 'Penie!', price: 0.8)
# => true
Product.where price: 0.8
# => [#<Product id: 1, name: "Penie!", price: 0.8, store_id: nil, actable_id: 1, actable_type: "Pen">]
pen = Pen.new
pen.valid?
# => false
pen.errors.full_messages
# => ["Name can't be blank", "Price can't be blank", "Color can't be blank"]
Pen.first.info
# => "Penie! $0.8"On the other hand you can always access a specific object from its parent by calling specific method on it:
Product.first.specific
# => #<Pen ...>If you have to come back to the parent object from the specific, the acting_as returns the parent element:
Pen.first.acting_as
# => #<Product ...>Likewise, actables converts a relation of specific objects to their parent objects:
Pen.where(...).actables
# => [#<Product ...>, ...]In has_many case you can use subclasses:
store = Store.create
store.products << Pen.create
store.products.first
# => #<Product: ...>You can give a name to all methods in :as option:
class Product < ActiveRecord::Base
actable as: :producible
end
class Pen < ActiveRecord::Base
acts_as :product, as: :producible
end
change_table :products do |t|
t.actable as: :producible
endacts_as support all has_one options, where defaults are there:
as: :actable, dependent: :destroy, validate: false, autosave: true
Make sure you know what you are doing when overwriting validate or autosave options.
You can pass scope to acts_as as in has_one:
acts_as :person, -> { includes(:friends) }actable support all belongs_to options, where defaults are these:
polymorphic: true, dependent: :destroy, autosave: true
Make sure you know what you are doing when overwriting polymorphic option.
If your actable and acts_as models are namespaced, you need to configure them like this:
class MyApp::Product < ApplicationRecord
actable inverse_of: :product
end
class MyApp::Pen < ApplicationRecord
acts_as :product, class_name: 'MyApp::Product'
endMultiple acts_as in the same class are not supported!
To use this library custom RSpec matchers, you must require the rspec/acts_as_matchers file.
Examples:
require "active_record/acts_as/matchers"
RSpec.describe "Pen acts like a Product" do
it { is_expected.to act_as(:product) }
it { is_expected.to act_as(Product) }
it { expect(Person).to act_as(:product) }
it { expect(Person).to act_as(Product) }
end
RSpec.describe "Product is actable" do
it { expect(Product).to be_actable }
end- Fork it (https://github.com/chaadow/active_record-acts_as/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Test changes don't break anything (
rspec) - Add specs for your new feature
- Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request