ActiveRecord::ActsAs
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.
Requirements
- Ruby >=
2.7
- ActiveSupport >=
6.0
( supportsmain
/edge branch ) - ActiveRecord >=
6.0
( supportsmain
/edge branch )
Installation
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
Usage
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
end
and 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
end
or use shortcut actable
change_table :products do |t|
t.actable
end
Make 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 Product
s 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
end
acts_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.
Namespaced models
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'
end
Caveats
Multiple acts_as
in the same class are not supported!
RSpec custom matchers
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
Contributing
- 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