Skip to content

chaadow/active_record-acts_as

Repository files navigation

ActiveRecord::ActsAs

Gem Build Status

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 ( supports main/edge branch )
  • ActiveRecord >= 6.0 ( supports main/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 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
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

  1. Fork it (https://github.com/chaadow/active_record-acts_as/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Test changes don't break anything (rspec)
  4. Add specs for your new feature
  5. Commit your changes (git commit -am 'Add some feature')
  6. Push to the branch (git push origin my-new-feature)
  7. Create a new Pull Request