Decoration

Decoration is an extension technique that allows Workarea applications and plugins to modify Ruby classes provided by the Workarea platform and other Ruby libraries. Ruby is a dynamic language that allows classes (and their instances) to be modified during runtime, however the syntax and APIs that Ruby provides for this purpose can be confusing to less experienced Ruby developers. Workarea therefore leverages Rails::Decorators (gem, docs, source), an open source Ruby library also maintained by Workarea, to simplify the process of extending classes.

Rails::Decorators specifies a DSL (based on Rails' ActiveSupport::Concern) to be used within decorators, which are Ruby files whose names end with .decorator. Each decorator extends one or more Ruby classes. Rails::Decorators ensures decorators within applications and plugins are autoloaded after Rails autoloads the class definitions from the application and its dependencies.

Decorators

Decorators allow application and plugin authors to extend existing Ruby classes in the following ways.

  • Add new instance and class methods to a class
  • Modify existing instance and class methods, with access to the pre-decoration implementation via super
  • Execute class macros or other code as if you were in the original class definition

Because decorators contain only differences from the classes they are extending, they are more lightweight than other extension techniques that completely replace the code to be customized. During an upgrade, if code you've decorated has changed, you may need to update your decorators. However, code changes you haven't decorated will be applied seamlessly to your application without additional upgrade cost.

Decorator Example

I extracted the following example from the Workarea Package Products plugin and present it here with minor edits and annotations to demonstrate the structure of a decorator. Review the Rails::Decorators documentation for more details.

# workarea-package_products-3.1.0/app/models/workarea/catalog/product.decorator
# The path of the decorator mimics the path of the class to be decorated

# Open namespace for convenience (to avoid fully qualified constants)
module Workarea
  # Pass the classes to be decorated and any options to 'decorate', along with a block
  # Decorators within plugins use the 'with' option to avoid naming collisions (see text below)
  decorate Catalog::Product, with: :package_products do
    # Code within the 'decorated' block is executed as if it were included in the class definition
    # Use this block to execute class macros or other metaprogramming
    decorated do
      include FeaturedProducts

      scope :packages_containing, ->(id) { where('product_ids' => id) }
    end

    # Use the 'class_methods' block to add and modify class methods
    class_methods do
      def find_for_update_by_sku(sku)
        where('variants.sku' => sku).flat_map do |product|
          [product] + packages_containing(product.id)
        end
      end
    end

    # Add and modify instance methods directly within the 'decorate' block

    def package?
      template == 'package' || product_ids.present?
    end

    def family?
      template == 'family'
    end

    def active?
      (read_attribute(:active) && variants.active.any? || product_ids.present?)
    end

    def purchasable?
      # Use 'super' to find the same method in the ancestor chain and invoke it
      # This provides access to the "pre-decorated" implementation (see examples below)
      super && package?
    end
  end
end

Decorating Tests

Because tests are Ruby methods, you can extend tests by decorating test cases, the classes in which test methods are defined. When decorating features, you should always decorate the corresponding tests as well.

The following examples from Workarea Browse Option demonstrate the need to decorate a feature and its tests together. In the first example, Browse Option decorates Search::ProductEntries so that products that "browse by option" are represented by multiple documents in Elasticsearch. This is new functionality, not covered by the existing test suite, so the plugin also decorates Search::ProductEntriesTest adding a new test to confirm the behavior.

# workarea-browse_option-1.1.0/app/queries/search/product_entries.decorator

module Workarea
  decorate Search::ProductEntries, with: :browse_option do
    def index_entries_for(product)
      if product.browses_by_option?
        product.browse_options.map do |value|
          Search::Storefront::ProductOption.new(
            product,
            option: product.browse_option,
            value: value
          )
        end
      else
        super
      end
    end
  end
end

# workarea-browse_option-1.1.0/test/queries/workarea/search/product_entries_test.decorator

require 'test_helper'

module Workarea
  decorate Search::ProductEntriesTest, with: :browse_option do
    def test_browse_option_entries
      products = Array.new(2) { create_product }

      products.first.update_attributes!(
        browse_option: 'color',
        variants: [
          { sku: 'SKU1', details: { color: ['Red'] } },
          { sku: 'SKU2', details: { color: ['Blue'] } }
        ]
      )

      assert(3, Search::ProductEntries.new(products).entries.size)
    end
  end
end

In the next example, Browse Option decorates BulkIndexProducts, changing the behavior of perform_by_models. This change breaks an existing test for perform, since perform_by_models is used in that method's implementation. The plugin therefore decorates BulkIndexProductsTest as well, in order to fix the test for perform.

# workarea-browse_option-1.1.0/app/workers/workarea/bulk_index_products.decorator

module Workarea
  decorate BulkIndexProducts, with: :browse_option do
    class_methods do
      def perform_by_models(products)
        return if products.blank?

        documents = delete_actions(products) +
          Search::ProductEntries.new(products).map(&:as_bulk_document)

        Search::Storefront.bulk(documents)
        products.each { |p| p.set(last_indexed_at: Time.current) }
      end

      # ...
    end
  end
end

# workarea-browse_option-1.1.0/test/workers/workarea/bulk_index_products_test.decorator

require 'test_helper'

module Workarea
  decorate BulkIndexProductsTest, with: :browse_option do
    def test_peform
      Workarea::Search::Storefront.reset_indexes!

      Sidekiq::Callbacks.disable(IndexProduct) do
        products = Array.new(2) { create_product }

        assert_equal(0, Search::Storefront.count)
        BulkIndexProducts.new.perform(products.map(&:id))
        assert_equal(2, Search::Storefront.count)

        products.first.update_attributes!(
          browse_option: 'color',
          variants: [
            { sku: 'SKU1', details: { color: ['Red'] } },
            { sku: 'SKU2', details: { color: ['Blue'] } }
          ]
        )

        assert_equal(2, Search::Storefront.count)
        BulkIndexProducts.new.perform(products.map(&:id))
        assert_equal(3, Search::Storefront.count)
      end
    end

    # ...
  end
end

Also see Writing Test Decorators within the Testing guide for more on this topic.

Compounding Decorators

Multiple engines may decorate the same class, in which case the effects of the decorators are cumulative. Decorators within the application are prepended last, giving them the opportunity to modify their classes after all plugin decorators.

For example, I've created a new application and added the following decorator within my app to begin implementing a loyalty program.

# app/models/workarea/catalog/product.decorator

module Workarea
  decorate Catalog::Product do
    decorated do
      field :loyalty_points, type: Integer, default: 100
    end

    def loyalty_promo?
      loyalty_points > 100
    end
  end
end

My application depends on several Workarea plugins.

$ grep 'workarea' Gemfile
gem 'workarea', '~> 3.1.0'
gem 'workarea-blog'
gem 'workarea-browse_option'
gem 'workarea-clothing'
gem 'workarea-content_search'
gem 'workarea-package_products'
gem 'workarea-reviews'
gem 'workarea-share'

Most of these plugins include the same decorator I've included in my application. In the example below, I search for the path workarea/catalog/product within my application and its dependencies. In the results, you can see the following.

  • The original Product model from Workarea Core
  • The Product decorator I added to my application
  • Four additional Product decorators, one each from Workarea Browse Option, Workarea Clothing, Workarea Package Products, and Workarea Reviews
$ find . -path '*workarea/catalog/product.*'
./app/models/workarea/catalog/product.decorator
./vendor/ruby/2.4.0/gems/workarea-browse_option-1.1.0/app/models/workarea/catalog/product.decorator
./vendor/ruby/2.4.0/gems/workarea-clothing-2.1.0/app/models/workarea/catalog/product.decorator
./vendor/ruby/2.4.0/gems/workarea-core-3.1.1/app/models/workarea/catalog/product.rb
./vendor/ruby/2.4.0/gems/workarea-package_products-3.1.0/app/models/workarea/catalog/product.decorator
./vendor/ruby/2.4.0/gems/workarea-reviews-2.1.0/app/models/workarea/catalog/product.decorator

To quickly demonstrate the effect of multiple decorators on the Product class, the following example (which I've annotated) lists the class's immediate ancestors.

$ bin/rails r 'puts Workarea::Catalog::Product.ancestors' | grep 'Workarea'
Workarea::Catalog::Product::ProductDecorator # application decorator
Workarea::Catalog::Product::ReviewsProductDecorator # |
Workarea::Catalog::Product::PackageProductsProductDecorator # |-- plugin decorators
Workarea::Catalog::Product::ClothingProductDecorator # |
Workarea::Catalog::Product::BrowseOptionProductDecorator # |
Workarea::Catalog::Product # original class
Workarea::FeaturedProducts
Workarea::Details
Workarea::Commentable
Workarea::Navigable
Workarea::Releasable
Workarea::ApplicationDocument

When looking up methods originally defined in this class, Ruby will actually look through the modules and classes as they are ordered above (top to bottom). Note the plugin decorator modules are searched before the original class, and they are searched in the opposite order the plugins are included in the Gemfile. Each plugin decorator module has a prefix that is derived from the value of the :with option in the decorator. The :with value must be unique to the ecosystem to avoid naming conflicts.

The application decorator module is searched first. Notice it does not have a prefix, because the :with option is omitted from the decorator, which is common practice for application decorators. Because the application decorator module is searched first, it has the responsibility of resolving any conflicts resulting from the culmination of the other decorators.

Super

Within a decorator's method definitions, calling super results in calling the same method on the closest ancestor in which it is defined. An example ancestor chain is shown above. As you can see from that example, a decorator may in fact be extending another decorator. Furthermore, calling super has various applications that may not be immediately obvious. The following examples, taken from various plugins, demonstrate uses of super within decorators.

In the following examples, command refers to a method concerned with side effects, while query refers to a method concerned with a return value.

Prepend to a Command

# workarea-browse_option-1.1.0/app/workers/workarea/index_product.decorator

module Workarea
  decorate IndexProduct, with: :browse_option do
    class_methods do
      def perform(product)
        clear(product)
        super
      end

      def clear(product)
        # ...
      end
    end
  end
end

Conditionally Append to a Command

# workarea-package_products-3.1.0/app/controllers/workarea/storefront/products_controller.decorator

module Workarea
  decorate Storefront::ProductsController, with: :package_products do
    def show
      super
      render 'package_show' if @product.package?
    end
  end
end

Conditionally Replace a Command

# workarea-content_search-1.0.1/app/controllers/workarea/storefront/searches_controller.decorator

module Workarea
  decorate Storefront::SearchesController, with: :content_search do
    # ...

    def set_search(response)
      if response.template == 'content'
        @search = Storefront::ContentSearchViewModel.new(response, view_model_options)
      else
        super
      end
    end
  end
end

Append to a Query

# workarea-reviews-2.1.0/app/models/workarea/search/storefront/product.decorator

module Workarea
  decorate Search::Storefront::Product, with: :reviews do
    def sorts
      super.merge(
        rating: Review.find_sorting_score(model.id)
      )
    end
  end
end

# workarea-reviews-2.1.0/app/queries/workarea/search/product_search.decorator

module Workarea
  decorate Search::ProductSearch, with: :reviews do
    class_methods do
      def available_sorts
        super.tap { |sorts| sorts << Sort.top_rated }
      end
    end
  end
end

Conditionally Prepend to a Query

# workarea-browse_option-1.1.0/app/view_models/workarea/storefront/product_view_model/cache_key.decorator

module Workarea
  decorate Storefront::ProductViewModel::CacheKey, with: :browse_option do
    # ...

    def option_parts
      option = @product.browse_option
      return super unless option.present? && @options[option].present?

      super.unshift(@options[option])
    end
  end
end

Conditionally Replace a Query

# workarea-package_products-3.1.0/app/queries/workarea/search/product_entries.decorator

module Workarea
  decorate Search::ProductEntries, with: :package_products do
    def index_entries_for(product)
      return Search::Storefront::PackageProduct.new(product) if product.package?
      super
    end
  end
end

Decorator Generator

Workarea provides a Rails generator that application developers can use to create a new decorator within an application. Given the path (relative to the engine root) to a file where a Workarea class is defined, the generator will create a decorator for that class within the application. The generator will also try to create decorators for applicable tests.

Run the generator with the --help option for documentation and examples. The following example is from my demonstration app running Workarea 3.1.1.

$ bin/rails g workarea:decorator --help
Usage:
  rails generate workarea:decorator PATH [options]

Runtime options:
  -f, [--force] # Overwrite files that already exist
  -p, [--pretend], [--no-pretend] # Run but do not make any changes
  -q, [--quiet], [--no-quiet] # Suppress status output
  -s, [--skip], [--no-skip] # Skip files that already exist

Description:
    Generates a new decorator for a given PATH in a Workarea platform
    component (or plugin), and a decorator for its unit test from the existing
    codebase in your host app.

Example:
    rails generate workarea:decorator app/models/workarea/search/storefront/product.rb

    This will create:
        app/models/workarea/search/storefront/product.decorator
        test/models/workarea/search/storefront/product_test.decorator (if a test exists)

    If no tests exist, it will also show a huge warning message stating the class you're
    about to decorate has NO tests, so anything you change must be also
    verified in the unit tests.