active_module

Gem Version License: MIT GitHub Actions Workflow Status 100% Coverage Gem Total Downloads

Modules and Classes as first-class active record values!

ActiveModel/ActiveRecord implementation of the Module attribute type.

  • Allows storing a reference to a Module or Class in a :string database field
  • Automatically casts strings and symbols into modules when creating and querying objects
  • Symbols or strings refer to the modules using unqualified names
  • It is safe and efficient

This is a very generic mechanism that enables many possible utilizations, for instance:

  • Composition-based polymorphism (Strategy design pattern)
  • Rapid prototyping static domain objects
  • Static configuration management
  • Rich Java/C#-like enums

You can find examples of these in Usage -> Examples.

TL;DR

Declare module attributes like this:

class MyARObject < ActiveRecord::Base
  attribute :module_field,
            :active_module,
            possible_modules: [MyModule1, MyClass, Nested::Module]

  # Optional: adds Rails-enum-like helpers (my_module1?, my_module1!,
  # with_my_module1, ...). Drop this line if you don't need them:
  active_module_enum :module_field
end

Assign them like this:

object.module_field = Nested::Module
object.module_field = :Module
object.module_field = "Module"
object.module_field = :nested_module          # underscored nested name
object.module_field                           #=> Nested::Module

Query them like this:

MyARObject.where(module_field: Nested::Module)
MyARObject.where(module_field: :Module)
MyARObject.where(module_field: "Module")
MyARObject.where(module_field: :nested_module) # underscored nested name

And compare them like this:

object.module_field == Nested::Module

module MyNameSpace
  using ActiveModule::Comparison

  object.module_field =~ :Module
  object.module_field =~ "Module"
  object.module_field =~ :nested_module     # underscored nested name
end

Installation

Add to your gemfile - and if you are using rails - that's all you need:

gem "active_module", "~> 0.8"

If you are not using rails, just issue this command after loading active record

ActiveModule.register!

or this, if you prefer to have a better idea of what you are doing:

ActiveModel::Type.register(:active_module, ActiveModule::Base)
ActiveRecord::Type.register(:active_module, ActiveModule::Base)

Usage

Add a string field to the table you want to hold a module attribute in your migrations:

create_table :my_ar_objects do |t|
  t.string :module_field, index: true
end

Now given this random module hierarchy:

class MyARObject < ActiveRecord::Base
  module MyModule1; end
  module MyModule2; end

  class MyClass
    module MyModule1; end
  end
end

You can make the field refer to one of these modules/classes like this:

class MyARObject < ActiveRecord::Base
  attribute :module_field,
            :active_module,
            possible_modules: [MyModule1, MyModule2, MyClass, MyClass::MyModule1]
end

Optionally, you can specify how to map your modules into the database (the default is the module's fully qualified name):

attribute :module_field,
          :active_module,
          possible_modules: [MyModule1, MyModule2, MyClass, MyClass::MyModule1],
          mapping: { MyModule1 => "m1" }

Modules not included in the mapping hash will use their fully qualified name as the database representation. Assignment and querying still work with module literals, symbols, and strings:

my_ar_object.module_field = :MyModule1
my_ar_object.module_field #=> MyARObject::MyModule1

MyARObject.where(module_field: :MyModule1)

The mapping only affects what is stored in the database column.

And this is it! Easy!

Assigning and querying module attributes

Now you can use this attribute in many handy ways!

The most ergonomic way is to use underscored symbols. For flat modules, use the underscored name directly:

MyARObject.create!(module_field: :my_module1)

MyARObject.where(module_field: :my_module1)

my_ar_object.module_field = :my_module1

my_ar_object.module_field #=> MyARObject::MyModule1

Nested modules can be referenced using underscored symbols at any nesting level:

MyARObject.create!(module_field: :my_class_my_module1)  # partial nesting
MyARObject.create!(module_field: :my_module1)           # demodulized name
MyARObject.where(module_field: :my_class_my_module1)    # all segments joined

my_ar_object.module_field = :my_class_my_module1
my_ar_object.module_field #=> MyARObject::MyClass::MyModule1

When a demodulized name is ambiguous (shared by modules at different nesting levels), the least-nested module always wins for assignment and querying:

# Given possible_modules: [MyModule1, MyClass::MyModule1]
# :MyModule1 resolves to the flat MyModule1 (not MyClass::MyModule1)

MyARObject.create!(module_field: :MyModule1)   # sets to MyModule1
MyARObject.where(module_field: :MyModule1)     # filters by MyModule1
my_ar_object.module_field = :MyModule1         # assigns MyModule1

# Use the underscored nested name for the nested module
MyARObject.where(module_field: :my_class_my_module1)  # filters by MyClass::MyModule1

You can always refer to modules using their fully qualified names via symbols or module literals:

MyARObject.create!(module_field: :MyModule1)
MyARObject.create!(module_field: MyARObject::MyModule1)

MyARObject.where(module_field: :MyModule1)
MyARObject.where(module_field: MyARObject::MyModule1)

And if there is the need for disambiguation, you can always use fully qualified strings:

MyARObject.create!(module_field: "MyClass::MyModule1")

MyARObject.where(module_field: "MyClass::MyModule1")

my_ar_object.module_field = "MyClass::MyModule1"

my_ar_object.module_field #=> MyARObject::MyClass::MyModule1

Comparing modules with strings and symbols

In order to compare modules with Strings or Symbols you'll have to use the ActiveModule::Comparison refinement. This refinement adds the method Module#=~ to the Module class, but this change is only available within the namespace that includes the refinement.

module YourClassOrModuleThatWantsToCompare
  using ActiveModule::Comparison

  def method_that_compares
    my_ar_object.module_field =~ :my_module1          # underscored name
    my_ar_object.module_field =~ :my_class_my_module1 # nested underscored
    my_ar_object.module_field =~ "MyClass::MyModule1" # fully qualified string
  end
end

or like this, if you don't want to use the refinement:

ActiveModule::Comparison.compare(my_ar_object.module_field, :my_module1)

but in this last case it would probably make more sense to simply use a module literal:

my_ar_object.module_field == MyClass::MyModule1

Examples

Composition-based polymorphism (Strategy design pattern)

The Strategy design pattern allows composition based polymorphism. This enables runtime polymorphism (by changing the strategy in runtime), and multiple-polymorphism (by composing an object of multiple strategies).

If you want to use classes this will do:

class MyARObject < ActiveRecord::Base
  attribute :strategy_class, :active_module, possible_modules: StrategySuperclass.subclasses

  def strategy
    @strategy ||= strategy_class.new(some_args_from_the_instance)
  end

  def run_strategy!(args)
    strategy.call(args)
  end
end

But if you are not in the mood to define a class hierarchy for it (or if you are performance-savy), you may use modules instead:

class MyARObject < ActiveRecord::Base
  module Strategy1
    def self.call
      "strategy1 called"
    end
  end

  module Strategy2
    def self.call
      "strategy2 called"
    end
  end

  attribute :strategy,
            :active_module,
            possible_modules: [Strategy1, Strategy2]

  def run_strategy!(some_args)
    strategy.call(some_args, other_args)
  end
end

MyARObject.create!(strategy: :Strategy1).run_strategy! #=> "strategy1 called"
MyARObject.create!(strategy: :Strategy2).run_strategy! #=> "strategy2 called"

You can later easily promote these modules to classes if you need instance variables:

class MyARObject < ActiveRecord::Base
  class Strategy1
    def self.call
      self.new.call
    end

    def call
      "strategy1 called"
    end
  end

  module Strategy2
    def self.call
      "strategy2 called"
    end
  end

  attribute :strategy,
            :active_module,
            possible_modules: [Strategy1, Strategy2]

  def run_strategy!(some_args)
    strategy.call(some_args, other_args)
  end
end

MyARObject.create!(strategy: :Strategy1).run_strategy! #=> "strategy1 called"
MyARObject.create!(strategy: :Strategy2).run_strategy! #=> "strategy2 called"

Rapid prototyping static domain objects

# Provider domain Object
module Provider
  # As if the domain model class
  def self.all
    [Ebay, Amazon]
  end

  # As if the domain model instances
  module Ebay
    def self.do_something!
      "do something with the ebay provider config"
    end
  end

  module Amazon
    def self.do_something!
      "do something with the amazon provider config"
    end
  end
end

class MyARObject < ActiveRecord::Base
  attribute :provider,
            :active_module,
            possible_modules: Provider.all
end

MyARObject.create!(provider: :Ebay).provider.do_something!
  #=> "do something with the ebay provider config"
MyARObject.create!(provider: Provider::Amazon).provider.do_something!
  #=> "do something with the amazon provider config"

What is interesting about this is that we can later easily promote our provider objects into full fledged ActiveRecord objects without big changes to our code:

class Provider < ActiveRecord::Base
  def do_something!
    #...
  end
end

class MyARObject < ActiveRecord::Base
  belongs_to :provider
end

Just in case you'd like to have shared code amongst the instances in the above example, this is how you could do so:

# Provider domain Object
module Provider
  # As if the domain model class
  def self.all
    [Ebay, Amazon]
  end

  module Base
    def do_something!
      "do something with #{something_from_an_instance}"
    end
  end

  # As if the domain model instances
  module Ebay
    include Base
    extend self

    def something_from_an_instance
      "the ebay provider config"
    end
  end

  module Amazon
    include Base
    extend self

    def something_from_an_instance
      "the amazon provider config"
    end
  end
end

Static configuration management

This example is not much different than previous one. It however stresses that the module we refer to might be used as a source of configuration parameters that change the behaviour of the class it belongs to:

# Provider domain Object
module ProviderConfig
  module Ebay
    module_function

    def url = "www.ebay.com"
    def number_of_attempts = 5
  end

  module Amazon
    module_function

    def url = "www.amazon.com"
    def number_of_attempts = 10
  end

  def self.all
    [Ebay, Amazon]
  end
end

class MyARObject < ActiveRecord::Base
  attribute :provider_config,
            :active_module,
            possible_modules: ProviderConfig.all

  def load_page!
    n_attempts = 0
    result = nil
    while n_attempts < provider_config.number_of_attempts
      result = get_page(provider_config.url)
      if result
        return result
      else
        n_attempts += 1
      end
    end
    result
  end
end

MyARObject.create!(provider_config: :Ebay).load_page!

Rich Java/C#-like enums with active_module_enum

Java/C# enums allow defining methods on the enum, which are shared across all enum values. ActiveModule supports this pattern with active_module_enum, which generates query, bang, and scope methods for your active_module attributes.

Nested modules can always be referenced using underscored names at any level of nesting. For example, given a deeply nested module:

module Lime
  module Banana
    module Strawberry; end
  end
end

class MyARObject < ActiveRecord::Base
  attribute :fruit,
            :active_module,
            possible_modules: [Lime::Banana::Strawberry]
end

All of the following resolve to Lime::Banana::Strawberry:

MyARObject.create!(fruit: :strawberry)              # last segment only
MyARObject.create!(fruit: :banana_strawberry)       # last two segments
MyARObject.create!(fruit: :lime_banana_strawberry)  # all segments

This also works for querying:

MyARObject.where(fruit: :banana_strawberry)

And for comparison (with ActiveModule::Comparison):

module MyModuleOrClass
  using ActiveModule::Comparison

  def self.match?(mod, value)
    mod =~ value
  end
end

MyModuleOrClass.match?(Lime::Banana::Strawberry, :banana_strawberry) #=> true

active_module_enum — generating query, bang, and scope methods

The active_module_enum method generates Rails-enum-style convenience methods for your active_module attributes:

module PipelineStage
  module_function

  def all
    [InitialContact, InNegotiations, LostDeal, PaidOut]
  end

  module Base
    def external_provider_code
      @external_provider_code ||= self.name.underscore
    end

    def frontend_representation
      @frontend_representation ||= self.name.demodulize.upcase
    end
  end

  module InitialContact; extend Base; end
  module InNegotiations; extend Base; end
  module LostDeal; extend Base; end
  module PaidOut; extend Base; end
end

class MyARObject < ActiveRecord::Base
  attribute :pipeline_stage,
            :active_module,
            possible_modules: PipelineStage.all

  active_module_enum :pipeline_stage
end

This generates:

# Instance query methods (?)
object = MyARObject.new(pipeline_stage: :initial_contact)
object.initial_contact?                    #=> true
object.lost_deal?                          #=> false

# Instance bang methods (!) — set and save
object.initial_contact!
object.reload
object.pipeline_stage #=> PipelineStage::InitialContact

# Class-level scopes
MyARObject.with_initial_contact            #=> ActiveRecord::Relation
MyARObject.with_lost_deal                  #=> ActiveRecord::Relation

# Class-level query methods (same as scopes)
MyARObject.initial_contact                 #=> ActiveRecord::Relation

The pluralized attribute name method returns a hash mapping modules to their fully qualified names:

MyARObject.pipeline_stages
#=> { PipelineStage::InitialContact => "PipelineStage::InitialContact",
#     PipelineStage::InNegotiations => "PipelineStage::InNegotiations",
#     PipelineStage::LostDeal => "PipelineStage::LostDeal",
#     PipelineStage::PaidOut => "PipelineStage::PaidOut" }

MyARObject.pipeline_stages.keys   #=> [PipelineStage::InitialContact, ...]
MyARObject.pipeline_stages.values #=> ["PipelineStage::InitialContact", ...]

All forms of underscored symbol names work for assignment and querying:

MyARObject.create!(pipeline_stage: :initial_contact)
MyARObject.create!(pipeline_stage: :in_negotiations)
MyARObject.where(pipeline_stage: :lost_deal)
MyARObject.where(pipeline_stage: :paid_out)

For nested modules, methods are generated at all nesting levels:

module Lime
  module Banana
    module Strawberry; end
  end
end

class Fruit < ActiveRecord::Base
  attribute :kind, :active_module,
            possible_modules: [Lime::Banana::Strawberry]
  active_module_enum :kind
end

object = Fruit.new(kind: :strawberry)
object.strawberry?                 #=> true
object.banana_strawberry?          #=> true (partial nesting)
object.lime_banana_strawberry?     #=> true (full nesting)

Fruit.with_banana_strawberry       #=> ActiveRecord::Relation

The pluralized attribute name method also works with nested modules. Each module maps to its fully qualified name:

module StatusA; end
module StatusB; end
module Nested
  module StatusA; end
  module StatusB; end
end

class Fruit < ActiveRecord::Base
  attribute :kind, :active_module,
            possible_modules: [StatusA, StatusB,
                               Nested::StatusA, Nested::StatusB]
  active_module_enum :kind
end

Fruit.kinds
#=> { StatusA => "StatusA", StatusB => "StatusB",
#     Nested::StatusA => "Nested::StatusA",
#     Nested::StatusB => "Nested::StatusB" }

All underscored forms work for assignment and querying:

Fruit.create!(kind: :strawberry)
Fruit.create!(kind: :banana_strawberry)
Fruit.create!(kind: :lime_banana_strawberry)
Fruit.where(kind: :banana_strawberry)
Options

active_module_enum accepts the following options:

active_module_enum :pipeline_stage,
                   prefix: true,       # prefix method names with the attribute name
                   suffix: true,       # suffix method names with the attribute name
                   scope: true,        # generate with_ scopes (default: true)
                   instance_methods: true,  # generate ? and ! methods (default: true)
                   on_ambiguous: :warn # :warn or :silence (default: :warn)
  • prefix: true — prefixes methods with the attribute name: pipeline_stage_initial_contact?, with_pipeline_stage_initial_contact
  • prefix: "custom" — prefixes with a custom string: custom_initial_contact?, with_custom_initial_contact
  • suffix: true — suffixes methods with the attribute name: initial_contact_pipeline_stage?, with_initial_contact_pipeline_stage
  • suffix: "custom" — suffixes with a custom string: initial_contact_custom?, with_initial_contact_custom
  • scope: false — skips scope generation
  • instance_methods: false — skips ? and ! method generation
  • on_ambiguous: :silence — suppresses warnings when multiple modules share the same demodulized name (e.g. Tino and Banana::Tino both producing tino?)
Ambiguity resolution

When two modules at different nesting levels produce the same demodulized name (e.g. Status and Nested::Status both mapping to status?), the least-nested module always wins across all contexts — assignment, querying, scopes, and enum methods:

module Status; end
module Nested
  module Status; end
end

class MyARObject < ActiveRecord::Base
  attribute :status, :active_module,
            possible_modules: [Status, Nested::Status]
  active_module_enum :status
end

# Assignment resolves to the flat module
object = MyARObject.new(status: :status)
object.status         #=> Status (flat, not Nested::Status)

# Querying resolves to the flat module
MyARObject.where(status: :status)       # filters by Status
MyARObject.find_by(status: "Status")    # finds Status

# Enum query resolves to the flat module
object.status?        #=> true (matches Status)
object.nested_status? #=> true (use underscored name for Nested::Status)

# Bang method resolves to the flat module
object.status!
object.reload
object.status         #=> Status

To access the nested module, always use its underscored form:

object.nested_status?         #=> true
object.nested_status!         #=> sets to Nested::Status
MyARObject.with_nested_status #=> ActiveRecord::Relation filtering by Nested::Status

This resolution applies consistently to with_ scopes, class-level query methods, find_by/where, and assignment via symbol or string — the least-nested module wins for the ambiguous name:

MyARObject.create!(status: Nested::Status)
MyARObject.create!(status: Status)

MyARObject.status.count             #=> 1 (flat Status only)
MyARObject.with_nested_status.count #=> 1 (Nested::Status only)

Note: when both prefix: true and suffix: true are set, only prefix takes effect.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/pedrorolo/active_module.

License

The gem is available as open source under the terms of the MIT License.