lib/mobility/plugin.rb
# frozen-string-literal: true
require "tsort"
require "set"
require "mobility/util"
module Mobility
=begin
Defines convenience methods on plugin module to hook into initialize/included
method calls on +Mobility::Pluggable+ instance.
- #initialize_hook: called after {Mobility::Pluggable#initialize}, with
attribute names.
- #included_hook: called after {Mobility::Pluggable#included}. (This can be
used to include any module(s) into the backend class, see
{Mobility::Plugins::Backend}.)
Also includes a +configure+ class method to apply plugins to a pluggable
({Mobility::Pluggable} instance), with a block.
@example Defining a plugin
module MyPlugin
extend Mobility::Plugin
initialize_hook do |*names|
names.each do |name|
define_method "#{name}_foo" do
# method body
end
end
end
included_hook do |klass, backend_class|
backend_class.include MyBackendMethods
klass.include MyModelMethods
end
end
@example Configure an attributes class with plugins
class Translations < Mobility::Translations
end
Mobility::Plugin.configure(Translations) do
cache
fallbacks
end
Translations.included_modules
#=> [Mobility::Plugins::Fallbacks, Mobility::Plugins::Cache, ...]
=end
module Plugin
class << self
# Configure a pluggable {Mobility::Pluggable} with a block. Yields to a
# clean room where plugin names define plugins on the module. Plugin
# dependencies are resolved before applying them.
#
# @param [Class, Module] pluggable
# @param [Hash] defaults Plugin defaults hash to update
# @yield Block to define plugins
# @return [Hash] Updated plugin defaults
# @raise [Mobility::Plugin::CyclicDependency] if dependencies cannot be met
# @example
# Mobility::Plugin.configure(Translations) do
# cache
# fallbacks [:en, :de]
# end
def configure(pluggable, defaults = pluggable.defaults, &block)
DependencyResolver.new(pluggable, defaults).call(&block)
end
end
def initialize_hook(&block)
plugin = self
define_method :initialize do |*args, **options|
super(*args, **options)
class_exec(*args, &block) if plugin.dependencies_satisfied?(self.class)
end
end
def included_hook(&block)
plugin = self
define_method :included do |klass|
super(klass).tap do |backend_class|
if plugin.dependencies_satisfied?(self.class)
class_exec(klass, backend_class, &block)
end
end
end
end
def included(pluggable)
if defined?(@default) && !pluggable.defaults.has_key?(name = Plugins.lookup_name(self))
pluggable.defaults[name] = @default
end
super
end
def dependencies
@dependencies ||= {}
end
def default(value)
@default = value
end
# Method called when defining plugins to assign a default based on
# arguments and keyword arguments to the plugin method. By default, we
# simply assign the first argument, but plugins can opt to customize this
# if additional arguments or keyword arguments are required.
# (The backend plugin uses keyword arguments to set backend options.)
#
# @param [Hash] defaults
# @param [Symbol] key Plugin key on hash
# @param [Array] args Method arguments
def configure_default(defaults, key, *args)
defaults[key] = args[0] unless args.empty?
end
# Does this class include all plugins this plugin depends (directly) on?
# @param [Class] klass Pluggable class
def dependencies_satisfied?(klass)
plugin_keys = klass.included_plugins.map { |plugin| Plugins.lookup_name(plugin) }
(dependencies.keys - plugin_keys).none?
end
# Specifies a dependency of this plugin.
#
# By default, the dependency is included (include: true). Passing +:before+
# or +:after+ will ensure the dependency is included before or after this
# plugin.
#
# Passing +false+ does not include the dependency, but checks that it has
# been included when running include and initialize hooks (so hooks will
# not run for this plugin if it has not been included). In other words:
# disable this plugin unless this dependency has been included elsewhere.
# (Note that this check is not applied recursively.)
#
# @param [Symbol] plugin Name of plugin dependency
# @option [TrueClass, FalseClass, Symbol] include
def requires(plugin, include: true)
unless [true, false, :before, :after].include?(include)
raise ArgumentError, "requires 'include' keyword argument must be one of: true, false, :before or :after"
end
dependencies[plugin] = include
end
DependencyResolver = Struct.new(:pluggable, :defaults) do
def call(&block)
plugins = DSL.call(defaults, &block)
tree = create_tree(plugins)
pluggable.include(*tree.tsort.reverse) unless tree.empty?
rescue TSort::Cyclic => e
raise_cyclic_dependency!(e.message)
end
private
def create_tree(plugins)
DependencyTree.new.tap do |tree|
visited = included_plugins
plugins.each { |plugin| traverse(tree, plugin, visited) }
end
end
def included_plugins
pluggable.included_modules.grep(Plugin)
end
# Recursively traverse dependencies and add their dependencies to tree
def traverse(tree, plugin, visited)
return if visited.include?(plugin)
tree.add(plugin)
plugin.dependencies.each do |dep_name, include_order|
next unless include_order
dep = Plugins.load_plugin(dep_name)
add_dependency(plugin, dep, tree, include_order)
traverse(tree, dep, visited << plugin)
end
end
def add_dependency(plugin, dep, tree, include_order)
case include_order
when :before
tree[plugin] += [dep]
when :after
check_after_dependency!(plugin, dep)
tree.add(dep)
tree[dep] += [plugin]
end
end
def check_after_dependency!(plugin, dep)
if included_plugins.include?(dep)
message = "'#{name(dep)}' plugin must come after '#{name(plugin)}' plugin"
raise DependencyConflict, append_pluggable_name(message)
end
end
def raise_cyclic_dependency!(error_message)
components = error_message.scan(/(?<=\[).*(?=\])/).first
names = components.split(', ').map! do |plugin|
name(Object.const_get(plugin)).to_s
end
message = "Dependencies cannot be resolved between: #{names.sort.join(', ')}"
raise CyclicDependency, append_pluggable_name(message)
end
def append_pluggable_name(message)
pluggable.name ? "#{message} in #{pluggable}" : message
end
def name(plugin)
Plugins.lookup_name(plugin)
end
class DependencyTree < Hash
include ::TSort
NO_DEPENDENCIES = Set.new.freeze
def add(key)
self[key] ||= NO_DEPENDENCIES
end
alias tsort_each_node each_key
def tsort_each_child(dep, &block)
self.fetch(dep, []).each(&block)
end
end
class DSL < BasicObject
def self.call(defaults, &block)
new(plugins = ::Set.new, defaults).instance_eval(&block)
plugins
end
def initialize(plugins, defaults)
@plugins = plugins
@defaults = defaults
end
def method_missing(m, *args)
plugin = Plugins.load_plugin(m)
@plugins << plugin
plugin.configure_default(@defaults, m, *args)
end
end
end
private_constant :DependencyResolver
class DependencyConflict < Mobility::Error; end
class CyclicDependency < DependencyConflict; end
end
end