ruby-on-railsrubyenumsactivesupport-concern

How to build dynamic enums in Rails 6 model concerns?


I'm trying to build a Bookable model concern that adds an enum to the including model, that's used for tracking the stage of the booking:

module Bookable
  extend ActiveSupport::Concern

  STAGES = {
    confirmed: 0,
    completed: 1,
    cancelled: 2,
    issue_raised: 3
  }.freeze

  included do
    enum stage: STAGES.merge(self.extra_stages)

    belongs_to :customer
    belongs_to :provider
    validates :stage, presence: true

    def self.extra_stages
      {}
    end
  end
end

The STAGES constant defines available stages, however I want including models to be able to add stages that are specific to them, by overriding the self.extra_stages method. For example, in a Mission model:

class Mission < ApplicationRecord
  include Bookable

  def self.extra_stages
    {
      awaiting_estimate: 4,
      awaiting_payment: 5,
      awaiting_report: 6,
      report_sent: 7
    }
  end
end

However, this code fails:

$ bundle exec rails c
Loading development environment (Rails 6.0.2.2)
[1] pry(main)> Mission.stages
NoMethodError: undefined method `extra_stages' for Mission (call 'Mission.connection' to establish a connection):Class
Did you mean?  extract_associated
from /home/gueorgui/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/activerecord-6.0.2.2/lib/active_record/dynamic_matchers.rb:22:in `method_missing'

Any clue as to what I might be doing wrong? Thank you in advance!


Solution

  • There are cleaner ways to do what I'm about to present, but it will accomplish what you're trying to do. Mind the brevity (I removed your associations to do a quick spot-check on my machine):

    module Bookable
      extend ActiveSupport::Concern
      included do 
        STAGES = {
          confirmed: 0,
          completed: 1,
          cancelled: 2,
          issue_raised: 3
        }.freeze
      end 
    end
    
    class ApplicationRecord < ActiveRecord::Base 
      self.abstract_class = true 
    
      def self.acts_as_bookable_with(extra_stages = {})
        include Bookable
    
        enum stage: self::STAGES.merge(extra_stages)
      end
    end 
    
    class Mission < ApplicationRecord
      acts_as_bookable_with({
          awaiting_estimate: 4,
          awaiting_payment: 5,
          awaiting_report: 6,
          report_sent: 7
        })
    end
    

    If you want to define these on the class, it would look like this:

    module Bookable
      extend ActiveSupport::Concern
      included do 
        STAGES = {
          confirmed: 0,
          completed: 1,
          cancelled: 2,
          issue_raised: 3
        }.freeze
      end 
    end
    
    class ApplicationRecord < ActiveRecord::Base 
      self.abstract_class = true 
    
      def self.acts_as_bookable_with(extra_stages)
        include Bookable
        if extra_stages.is_a?(Symbol)
          extra_stages = self.send(extra_stages)
        elsif extra_stages.is_a?(Hash)
          # do nothing
        else
          raise TypeError, "can't find extra_stages from #{extra_stages.inspect}"
        end
        stages = self::STAGES.merge(extra_stages)
        enum stage: stages
      end
    end 
    
    class Comment < ApplicationRecord
      def self.extra_stages
        {
          awaiting_estimate: 4,
          awaiting_payment: 5,
          awaiting_report: 6,
          report_sent: 7
        }
      end
    
      acts_as_bookable_with(:extra_stages)
    
    end
    

    Note that we're calling acts_as_bookable_with after we define our class method. Otherwise, we'll get undefined method error.

    There isn't a whole lot of "bad" in having this in ApplicationRecord. It's not the most ideal way of doing it, but most of these acts_as_* modules follow this exact pattern anyway and inject into ActiveRecord::Base.