mobility 0.8.13 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data.tar.gz.sig +0 -0
  4. data/CHANGELOG.md +63 -0
  5. data/Gemfile +5 -2
  6. data/Gemfile.lock +39 -20
  7. data/README.md +183 -93
  8. data/lib/mobility.rb +101 -169
  9. data/lib/mobility/backend.rb +27 -51
  10. data/lib/mobility/backends.rb +20 -0
  11. data/lib/mobility/backends/active_record.rb +4 -0
  12. data/lib/mobility/backends/active_record/column.rb +3 -1
  13. data/lib/mobility/backends/active_record/container.rb +10 -11
  14. data/lib/mobility/backends/active_record/hstore.rb +6 -4
  15. data/lib/mobility/backends/active_record/json.rb +5 -3
  16. data/lib/mobility/backends/active_record/jsonb.rb +5 -3
  17. data/lib/mobility/backends/active_record/key_value.rb +31 -13
  18. data/lib/mobility/backends/active_record/pg_hash.rb +1 -1
  19. data/lib/mobility/backends/active_record/serialized.rb +6 -0
  20. data/lib/mobility/backends/active_record/table.rb +17 -10
  21. data/lib/mobility/backends/column.rb +0 -6
  22. data/lib/mobility/backends/container.rb +10 -1
  23. data/lib/mobility/backends/hash.rb +39 -0
  24. data/lib/mobility/backends/hash_valued.rb +4 -0
  25. data/lib/mobility/backends/hstore.rb +0 -1
  26. data/lib/mobility/backends/json.rb +0 -1
  27. data/lib/mobility/backends/jsonb.rb +1 -2
  28. data/lib/mobility/backends/key_value.rb +31 -26
  29. data/lib/mobility/backends/null.rb +2 -0
  30. data/lib/mobility/backends/sequel.rb +37 -2
  31. data/lib/mobility/backends/sequel/column.rb +2 -0
  32. data/lib/mobility/backends/sequel/container.rb +11 -9
  33. data/lib/mobility/backends/sequel/hstore.rb +3 -1
  34. data/lib/mobility/backends/sequel/json.rb +3 -0
  35. data/lib/mobility/backends/sequel/jsonb.rb +3 -1
  36. data/lib/mobility/backends/sequel/key_value.rb +87 -18
  37. data/lib/mobility/backends/sequel/pg_hash.rb +6 -6
  38. data/lib/mobility/backends/sequel/serialized.rb +6 -0
  39. data/lib/mobility/backends/sequel/table.rb +22 -9
  40. data/lib/mobility/backends/serialized.rb +1 -3
  41. data/lib/mobility/backends/table.rb +39 -31
  42. data/lib/mobility/pluggable.rb +56 -0
  43. data/lib/mobility/plugin.rb +260 -0
  44. data/lib/mobility/plugins.rb +27 -24
  45. data/lib/mobility/plugins/active_model.rb +17 -0
  46. data/lib/mobility/plugins/active_model/cache.rb +26 -0
  47. data/lib/mobility/plugins/active_model/dirty.rb +119 -78
  48. data/lib/mobility/plugins/active_record.rb +37 -0
  49. data/lib/mobility/plugins/active_record/backend.rb +27 -0
  50. data/lib/mobility/plugins/active_record/cache.rb +28 -0
  51. data/lib/mobility/plugins/active_record/dirty.rb +34 -17
  52. data/lib/mobility/plugins/active_record/query.rb +43 -31
  53. data/lib/mobility/plugins/active_record/uniqueness_validation.rb +64 -0
  54. data/lib/mobility/plugins/arel.rb +125 -0
  55. data/lib/mobility/plugins/arel/nodes.rb +15 -0
  56. data/lib/mobility/plugins/arel/nodes/pg_ops.rb +134 -0
  57. data/lib/mobility/plugins/attribute_methods.rb +29 -20
  58. data/lib/mobility/plugins/attributes.rb +72 -0
  59. data/lib/mobility/plugins/backend.rb +161 -0
  60. data/lib/mobility/plugins/backend_reader.rb +34 -0
  61. data/lib/mobility/plugins/cache.rb +68 -26
  62. data/lib/mobility/plugins/default.rb +22 -17
  63. data/lib/mobility/plugins/dirty.rb +12 -33
  64. data/lib/mobility/plugins/fallbacks.rb +52 -44
  65. data/lib/mobility/plugins/fallthrough_accessors.rb +19 -23
  66. data/lib/mobility/plugins/locale_accessors.rb +22 -35
  67. data/lib/mobility/plugins/presence.rb +28 -21
  68. data/lib/mobility/plugins/query.rb +8 -17
  69. data/lib/mobility/plugins/reader.rb +50 -0
  70. data/lib/mobility/plugins/sequel.rb +34 -0
  71. data/lib/mobility/plugins/sequel/backend.rb +25 -0
  72. data/lib/mobility/plugins/sequel/cache.rb +24 -0
  73. data/lib/mobility/plugins/sequel/dirty.rb +34 -23
  74. data/lib/mobility/plugins/sequel/query.rb +21 -6
  75. data/lib/mobility/plugins/writer.rb +44 -0
  76. data/lib/mobility/translations.rb +95 -0
  77. data/lib/mobility/version.rb +12 -1
  78. data/lib/rails/generators/mobility/templates/create_string_translations.rb +0 -1
  79. data/lib/rails/generators/mobility/templates/create_text_translations.rb +0 -1
  80. data/lib/rails/generators/mobility/templates/initializer.rb +104 -78
  81. metadata +35 -40
  82. metadata.gz.sig +0 -0
  83. data/lib/mobility/active_model.rb +0 -4
  84. data/lib/mobility/active_model/backend_resetter.rb +0 -26
  85. data/lib/mobility/active_record.rb +0 -23
  86. data/lib/mobility/active_record/backend_resetter.rb +0 -26
  87. data/lib/mobility/active_record/model_translation.rb +0 -14
  88. data/lib/mobility/active_record/string_translation.rb +0 -10
  89. data/lib/mobility/active_record/text_translation.rb +0 -10
  90. data/lib/mobility/active_record/translation.rb +0 -14
  91. data/lib/mobility/active_record/uniqueness_validator.rb +0 -60
  92. data/lib/mobility/arel.rb +0 -49
  93. data/lib/mobility/arel/nodes.rb +0 -13
  94. data/lib/mobility/arel/nodes/pg_ops.rb +0 -132
  95. data/lib/mobility/arel/visitor.rb +0 -61
  96. data/lib/mobility/attributes.rb +0 -324
  97. data/lib/mobility/backend/orm_delegator.rb +0 -44
  98. data/lib/mobility/backend_resetter.rb +0 -50
  99. data/lib/mobility/configuration.rb +0 -138
  100. data/lib/mobility/fallbacks.rb +0 -28
  101. data/lib/mobility/interface.rb +0 -0
  102. data/lib/mobility/loaded.rb +0 -4
  103. data/lib/mobility/plugins/active_record/attribute_methods.rb +0 -38
  104. data/lib/mobility/plugins/cache/translation_cacher.rb +0 -40
  105. data/lib/mobility/sequel.rb +0 -9
  106. data/lib/mobility/sequel/backend_resetter.rb +0 -23
  107. data/lib/mobility/sequel/column_changes.rb +0 -28
  108. data/lib/mobility/sequel/hash_initializer.rb +0 -21
  109. data/lib/mobility/sequel/model_translation.rb +0 -20
  110. data/lib/mobility/sequel/sql.rb +0 -16
  111. data/lib/mobility/sequel/string_translation.rb +0 -10
  112. data/lib/mobility/sequel/text_translation.rb +0 -10
  113. data/lib/mobility/sequel/translation.rb +0 -53
  114. data/lib/mobility/translates.rb +0 -73
@@ -2,8 +2,6 @@
2
2
  require "mobility/util"
3
3
  require "mobility/backends/sequel"
4
4
  require "mobility/backends/hash_valued"
5
- require "mobility/sequel/column_changes"
6
- require "mobility/sequel/hash_initializer"
7
5
 
8
6
  module Mobility
9
7
  module Backends
@@ -35,10 +33,12 @@ jsonb).
35
33
  model[column_name.to_sym]
36
34
  end
37
35
 
36
+ backend = self
37
+
38
38
  setup do |attributes, options|
39
39
  columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym }
40
40
 
41
- before_validation = Module.new do
41
+ mod = Module.new do
42
42
  define_method :before_validation do
43
43
  columns.each do |column|
44
44
  self[column].delete_if { |_, v| Util.blank?(v) }
@@ -46,9 +46,9 @@ jsonb).
46
46
  super()
47
47
  end
48
48
  end
49
- include before_validation
50
- include Mobility::Sequel::HashInitializer.new(*columns)
51
- include Mobility::Sequel::ColumnChanges.new(attributes, column_affix: options[:column_affix])
49
+ include mod
50
+ backend.define_hash_initializer(mod, columns)
51
+ backend.define_column_changes(mod, attributes, column_affix: options[:column_affix])
52
52
 
53
53
  plugin :defaults_setter
54
54
  columns.each { |column| default_values[column] = {} }
@@ -36,6 +36,10 @@ Sequel serialization plugin.
36
36
  include Sequel
37
37
  include HashValued
38
38
 
39
+ def self.valid_keys
40
+ super + [:format]
41
+ end
42
+
39
43
  # @!group Backend Configuration
40
44
  # @param (see Backends::Serialized.configure)
41
45
  # @option (see Backends::Serialized.configure)
@@ -110,5 +114,7 @@ Sequel serialization plugin.
110
114
  super.to_sym
111
115
  end
112
116
  end
117
+
118
+ register_backend(:sequel_serialized, Sequel::Serialized)
113
119
  end
114
120
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "mobility/util"
3
3
  require "mobility/backends/sequel"
4
- require "mobility/backends/key_value"
5
- require "mobility/sequel/model_translation"
6
- require "mobility/sequel/sql"
4
+ require "mobility/backends/table"
7
5
 
8
6
  module Mobility
9
7
  module Backends
@@ -34,7 +32,7 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
34
32
  # @raise [CacheRequired] if cache option is false
35
33
  def configure(options)
36
34
  raise CacheRequired, "Cache required for Sequel::Table backend" if options[:cache] == false
37
- table_name = Util.singularize(options[:model_class].table_name)
35
+ table_name = Util.singularize(model_class.table_name)
38
36
  options[:table_name] ||= :"#{table_name}_translations"
39
37
  options[:foreign_key] ||= Util.foreign_key(Util.camelize(table_name.downcase))
40
38
  if association_name = options[:association_name]
@@ -51,7 +49,7 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
51
49
  # @param [Symbol] locale Locale
52
50
  # @return [Sequel::SQL::QualifiedIdentifier]
53
51
  def build_op(attr, locale)
54
- ::Mobility::Sequel::SQL::QualifiedIdentifier.new(table_alias(locale), attr, locale, self, attribute_name: attr)
52
+ ::Sequel::SQL::QualifiedIdentifier.new(table_alias(locale), attr || :value)
55
53
  end
56
54
 
57
55
  # @param [Sequel::Dataset] dataset Dataset to prepare
@@ -82,7 +80,7 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
82
80
  case predicate
83
81
  when Array
84
82
  visit_collection(predicate, locale)
85
- when ::Mobility::Sequel::SQL::QualifiedIdentifier
83
+ when ::Sequel::SQL::QualifiedIdentifier
86
84
  visit_sql_identifier(predicate, locale)
87
85
  when ::Sequel::SQL::BooleanExpression
88
86
  visit_boolean(predicate, locale)
@@ -118,6 +116,8 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
118
116
  end
119
117
  end
120
118
 
119
+ backend = self
120
+
121
121
  setup do |attributes, options|
122
122
  association_name = options[:association_name]
123
123
  subclass_name = options[:subclass_name]
@@ -127,7 +127,7 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
127
127
  const_get(subclass_name, false)
128
128
  else
129
129
  const_set(subclass_name, Class.new(::Sequel::Model(options[:table_name]))).tap do |klass|
130
- klass.include ::Mobility::Sequel::ModelTranslation
130
+ klass.include Translation
131
131
  end
132
132
  end
133
133
 
@@ -154,10 +154,11 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
154
154
  end
155
155
  include callback_methods
156
156
 
157
- include Mobility::Sequel::ColumnChanges.new(attributes)
157
+ include(mod = Module.new)
158
+ backend.define_column_changes(mod, attributes)
158
159
  end
159
160
 
160
- def translation_for(locale, _)
161
+ def translation_for(locale, **)
161
162
  translation = model.send(association_name).find { |t| t.locale == locale.to_s }
162
163
  translation ||= translation_class.new(locale: locale)
163
164
  translation
@@ -173,7 +174,19 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
173
174
  end
174
175
  end
175
176
 
177
+ module Translation
178
+ def self.included(base)
179
+ base.plugin :validation_helpers
180
+ end
181
+
182
+ def validate
183
+ super
184
+ validates_presence [:locale]
185
+ end
186
+ end
176
187
  class CacheRequired < ::StandardError; end
177
188
  end
189
+
190
+ register_backend(:sequel_table, Sequel::Table)
178
191
  end
179
192
  end
@@ -23,8 +23,6 @@ Format for serialization. Either +:yaml+ (default) or +:json+.
23
23
 
24
24
  =end
25
25
  module Serialized
26
- extend Backend::OrmDelegator
27
-
28
26
  class << self
29
27
 
30
28
  # @!group Backend Configuration
@@ -40,7 +38,7 @@ Format for serialization. Either +:yaml+ (default) or +:json+.
40
38
  def serializer_for(format)
41
39
  lambda do |obj|
42
40
  return if obj.nil?
43
- if obj.is_a? Hash
41
+ if obj.is_a? ::Hash
44
42
  obj = obj.inject({}) do |translations, (locale, value)|
45
43
  translations[locale] = value.to_s if Util.present?(value)
46
44
  translations
@@ -10,8 +10,9 @@ Stores attribute translation as rows on a model-specific translation table
10
10
  the table name for a model +Post+ with table +posts+ will be
11
11
  +post_translations+, and the translation class will be +Post::Translation+. The
12
12
  translation class is dynamically created when the backend is initialized on the
13
- model class, and subclasses {Mobility::ActiveRecord::ModelTranslation} (for AR
14
- models) or inherits {Mobility::Sequel::ModelTranslation} (for Sequel models).
13
+ model class, and subclasses
14
+ {Mobility::Backends::ActiveRecord::Table::Translation} (for AR models) or
15
+ inherits {Mobility::Backends::Sequel::Table::Translation} (for Sequel models).
15
16
 
16
17
  The backend expects the translations table (+post_translations+) to have:
17
18
 
@@ -65,7 +66,6 @@ set.
65
66
  @see Mobility::Backends::Sequel::Table
66
67
  =end
67
68
  module Table
68
- extend Backend::OrmDelegator
69
69
  # @!method association_name
70
70
  # Returns the name of the translations association.
71
71
  # @return [Symbol] Name of the association
@@ -84,13 +84,13 @@ set.
84
84
 
85
85
  # @!group Backend Accessors
86
86
  # @!macro backend_reader
87
- def read(locale, options = {})
88
- translation_for(locale, options).send(attribute)
87
+ def read(locale, **options)
88
+ translation_for(locale, **options).send(attribute)
89
89
  end
90
90
 
91
91
  # @!macro backend_writer
92
- def write(locale, value, options = {})
93
- translation_for(locale, options).send("#{attribute}=", value)
92
+ def write(locale, value, **options)
93
+ translation_for(locale, **options).send("#{attribute}=", value)
94
94
  end
95
95
  # @!endgroup
96
96
 
@@ -105,25 +105,22 @@ set.
105
105
  model.send(association_name)
106
106
  end
107
107
 
108
- def self.included(backend)
109
- backend.extend ClassMethods
110
- backend.option_reader :association_name
111
- backend.option_reader :subclass_name
112
- backend.option_reader :foreign_key
113
- backend.option_reader :table_name
108
+ def self.included(backend_class)
109
+ backend_class.extend ClassMethods
110
+ backend_class.option_reader :association_name
111
+ backend_class.option_reader :subclass_name
112
+ backend_class.option_reader :foreign_key
113
+ backend_class.option_reader :table_name
114
114
  end
115
115
 
116
116
  module ClassMethods
117
- # Apply custom processing for plugin
118
- # @param (see Backend::Setup#apply_plugin)
119
- # @return (see Backend::Setup#apply_plugin)
120
- def apply_plugin(name)
121
- if name == :cache
122
- include self::Cache
123
- true
124
- else
125
- super
126
- end
117
+ def valid_keys
118
+ [:association_name, :subclass_name, :foreign_key, :table_name]
119
+ end
120
+
121
+ # Apply custom processing for cache plugin
122
+ def include_cache
123
+ include self::Cache
127
124
  end
128
125
 
129
126
  def table_alias(locale)
@@ -134,20 +131,31 @@ set.
134
131
  # Simple hash cache to memoize translations as a hash so they can be
135
132
  # fetched quickly.
136
133
  module Cache
137
- include Plugins::Cache::TranslationCacher.new(:translation_for)
134
+ def translation_for(locale, **options)
135
+ return super(locale, options) if options.delete(:cache) == false
136
+ if cache.has_key?(locale)
137
+ cache[locale]
138
+ else
139
+ cache[locale] = super(locale, **options)
140
+ end
141
+ end
142
+
143
+ def clear_cache
144
+ cache.clear
145
+ end
138
146
 
139
147
  private
140
148
 
141
149
  def cache
142
- model_cache || model.instance_variable_set(:"@__mobility_#{association_name}_cache", {})
143
- end
144
-
145
- def model_cache
146
- model.instance_variable_get(:"@__mobility_#{association_name}_cache")
150
+ if model.instance_variable_defined?(cache_name)
151
+ model.instance_variable_get(cache_name)
152
+ else
153
+ model.instance_variable_set(cache_name, {})
154
+ end
147
155
  end
148
156
 
149
- def clear_cache
150
- model_cache && model_cache.clear
157
+ def cache_name
158
+ @cache_name ||= :"@__mobility_#{association_name}_cache"
151
159
  end
152
160
  end
153
161
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobility
4
+ =begin
5
+
6
+ Abstract Module subclass with methods to define plugins and defaults.
7
+ Works with {Mobility::Plugin}. (Subclassed by {Mobility::Translations}.)
8
+
9
+ =end
10
+ class Pluggable < Module
11
+ class << self
12
+ def plugin(name, *args)
13
+ Plugin.configure(self, defaults) { __send__ name, *args }
14
+ end
15
+
16
+ def plugins(&block)
17
+ Plugin.configure(self, defaults, &block)
18
+ end
19
+
20
+ def included_plugins
21
+ included_modules.grep(Plugin)
22
+ end
23
+
24
+ def defaults
25
+ @defaults ||= {}
26
+ end
27
+
28
+ def inherited(klass)
29
+ super
30
+ klass.defaults.merge!(defaults)
31
+ end
32
+ end
33
+
34
+ def initialize(*, **options)
35
+ initialize_options(options)
36
+ validate_options(@options)
37
+ end
38
+
39
+ attr_reader :options
40
+
41
+ private
42
+
43
+ def initialize_options(options)
44
+ @options = self.class.defaults.merge(options)
45
+ end
46
+
47
+ # This is overridden by backend plugin to exclude mixed-in backend options.
48
+ def validate_options(options)
49
+ plugin_keys = self.class.included_plugins.map { |p| Plugins.lookup_name(p) }
50
+ extra_keys = options.keys - plugin_keys
51
+ raise InvalidOptionKey, "No plugin configured for these keys: #{extra_keys.join(', ')}." unless extra_keys.empty?
52
+ end
53
+
54
+ class InvalidOptionKey < Error; end
55
+ end
56
+ end
@@ -0,0 +1,260 @@
1
+ # frozen-string-literal: true
2
+ require "tsort"
3
+ require "mobility/util"
4
+
5
+ module Mobility
6
+ =begin
7
+
8
+ Defines convenience methods on plugin module to hook into initialize/included
9
+ method calls on +Mobility::Pluggable+ instance.
10
+
11
+ - #initialize_hook: called after {Mobility::Pluggable#initialize}, with
12
+ attribute names.
13
+ - #included_hook: called after {Mobility::Pluggable#included}. (This can be
14
+ used to include any module(s) into the backend class, see
15
+ {Mobility::Plugins::Backend}.)
16
+
17
+ Also includes a +configure+ class method to apply plugins to a pluggable
18
+ ({Mobility::Pluggable} instance), with a block.
19
+
20
+ @example Defining a plugin
21
+ module MyPlugin
22
+ extend Mobility::Plugin
23
+
24
+ initialize_hook do |*names|
25
+ names.each do |name|
26
+ define_method "#{name}_foo" do
27
+ # method body
28
+ end
29
+ end
30
+ end
31
+
32
+ included_hook do |klass, backend_class|
33
+ backend_class.include MyBackendMethods
34
+ klass.include MyModelMethods
35
+ end
36
+ end
37
+
38
+ @example Configure an attributes class with plugins
39
+ class Translations < Mobility::Translations
40
+ end
41
+
42
+ Mobility::Plugin.configure(Translations) do
43
+ cache
44
+ fallbacks
45
+ end
46
+
47
+ Translations.included_modules
48
+ #=> [Mobility::Plugins::Fallbacks, Mobility::Plugins::Cache, ...]
49
+ =end
50
+ module Plugin
51
+ class << self
52
+ # Configure a pluggable {Mobility::Pluggable} with a block. Yields to a
53
+ # clean room where plugin names define plugins on the module. Plugin
54
+ # dependencies are resolved before applying them.
55
+ #
56
+ # @param [Class, Module] pluggable
57
+ # @param [Hash] defaults Plugin defaults hash to update
58
+ # @yield Block to define plugins
59
+ # @return [Hash] Updated plugin defaults
60
+ # @raise [Mobility::Plugin::CyclicDependency] if dependencies cannot be met
61
+ # @example
62
+ # Mobility::Plugin.configure(Translations) do
63
+ # cache
64
+ # fallbacks [:en, :de]
65
+ # end
66
+ def configure(pluggable, defaults = pluggable.defaults, &block)
67
+ DependencyResolver.new(pluggable, defaults).call(&block)
68
+ end
69
+ end
70
+
71
+ def initialize_hook(&block)
72
+ plugin = self
73
+
74
+ define_method :initialize do |*args, **options|
75
+ super(*args, **options)
76
+
77
+ class_exec(*args, &block) if plugin.dependencies_satisfied?(self.class)
78
+ end
79
+ end
80
+
81
+ def included_hook(&block)
82
+ plugin = self
83
+
84
+ define_method :included do |klass|
85
+ super(klass).tap do |backend_class|
86
+ if plugin.dependencies_satisfied?(self.class)
87
+ class_exec(klass, backend_class, &block)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ def included(pluggable)
94
+ if defined?(@default) && !pluggable.defaults.has_key?(name = Plugins.lookup_name(self))
95
+ pluggable.defaults[name] = @default
96
+ end
97
+ super
98
+ end
99
+
100
+ def dependencies
101
+ @dependencies ||= {}
102
+ end
103
+
104
+ def default(value)
105
+ @default = value
106
+ end
107
+
108
+ # Method called when defining plugins to assign a default based on
109
+ # arguments and keyword arguments to the plugin method. By default, we
110
+ # simply assign the first argument, but plugins can opt to customize this
111
+ # if additional arguments or keyword arguments are required.
112
+ # (The backend plugin uses keyword arguments to set backend options.)
113
+ #
114
+ # @param [Hash] defaults
115
+ # @param [Symbol] key Plugin key on hash
116
+ # @param [Array] args Method arguments
117
+ def configure_default(defaults, key, *args)
118
+ defaults[key] = args[0] unless args.empty?
119
+ end
120
+
121
+ # Does this class include all plugins this plugin depends (directly) on?
122
+ # @param [Class] klass Pluggable class
123
+ def dependencies_satisfied?(klass)
124
+ required_plugins = dependencies.keys.map { |name| Plugins.load_plugin(name) }
125
+ (required_plugins - klass.included_modules).none?
126
+ end
127
+
128
+ # Specifies a dependency of this plugin.
129
+ #
130
+ # By default, the dependency is included (include: true). Passing +:before+
131
+ # or +:after+ will ensure the dependency is included before or after this
132
+ # plugin.
133
+ #
134
+ # Passing +false+ does not include the dependency, but checks that it has
135
+ # been included when running include and initialize hooks (so hooks will
136
+ # not run for this plugin if it has not been included). In other words:
137
+ # disable this plugin unless this dependency has been included elsewhere.
138
+ # (Note that this check is not applied recursively.)
139
+ #
140
+ # @param [Symbol] plugin Name of plugin dependency
141
+ # @option [TrueClass, FalseClass, Symbol] include
142
+ def requires(plugin, include: true)
143
+ unless [true, false, :before, :after].include?(include)
144
+ raise ArgumentError, "requires 'include' keyword argument must be one of: true, false, :before or :after"
145
+ end
146
+ dependencies[plugin] = include
147
+ end
148
+
149
+ DependencyResolver = Struct.new(:pluggable, :defaults) do
150
+ def call(&block)
151
+ plugins = DSL.call(defaults, &block)
152
+ tree = create_tree(plugins)
153
+
154
+ pluggable.include(*tree.tsort.reverse) unless tree.empty?
155
+ rescue TSort::Cyclic => e
156
+ raise_cyclic_dependency!(e.message)
157
+ end
158
+
159
+ private
160
+
161
+ def create_tree(plugins)
162
+ DependencyTree.new.tap do |tree|
163
+ visited = included_plugins
164
+ plugins.each { |plugin| traverse(tree, plugin, visited) }
165
+ end
166
+ end
167
+
168
+ def included_plugins
169
+ pluggable.included_modules.grep(Plugin)
170
+ end
171
+
172
+ # Recursively traverse dependencies and add their dependencies to tree
173
+ def traverse(tree, plugin, visited)
174
+ return if visited.include?(plugin)
175
+
176
+ tree.add(plugin)
177
+
178
+ plugin.dependencies.each do |dep_name, include_order|
179
+ next unless include_order
180
+ dep = Plugins.load_plugin(dep_name)
181
+ add_dependency(plugin, dep, tree, include_order)
182
+
183
+ traverse(tree, dep, visited << plugin)
184
+ end
185
+ end
186
+
187
+ def add_dependency(plugin, dep, tree, include_order)
188
+ case include_order
189
+ when :before
190
+ tree[plugin] += [dep]
191
+ when :after
192
+ check_after_dependency!(plugin, dep)
193
+ tree.add(dep)
194
+ tree[dep] += [plugin]
195
+ end
196
+ end
197
+
198
+ def check_after_dependency!(plugin, dep)
199
+ if included_plugins.include?(dep)
200
+ message = "'#{name(dep)}' plugin must come after '#{name(plugin)}' plugin"
201
+ raise DependencyConflict, append_pluggable_name(message)
202
+ end
203
+ end
204
+
205
+ def raise_cyclic_dependency!(error_message)
206
+ components = error_message.scan(/(?<=\[).*(?=\])/).first
207
+ names = components.split(', ').map! do |plugin|
208
+ name(Object.const_get(plugin)).to_s
209
+ end
210
+ message = "Dependencies cannot be resolved between: #{names.sort.join(', ')}"
211
+ raise CyclicDependency, append_pluggable_name(message)
212
+ end
213
+
214
+ def append_pluggable_name(message)
215
+ pluggable.name ? "#{message} in #{pluggable}" : message
216
+ end
217
+
218
+ def name(plugin)
219
+ Plugins.lookup_name(plugin)
220
+ end
221
+
222
+ class DependencyTree < Hash
223
+ include ::TSort
224
+ NO_DEPENDENCIES = Set.new.freeze
225
+
226
+ def add(key)
227
+ self[key] ||= NO_DEPENDENCIES
228
+ end
229
+
230
+ alias tsort_each_node each_key
231
+
232
+ def tsort_each_child(dep, &block)
233
+ self.fetch(dep, []).each(&block)
234
+ end
235
+ end
236
+
237
+ class DSL < BasicObject
238
+ def self.call(defaults, &block)
239
+ new(plugins = ::Set.new, defaults).instance_eval(&block)
240
+ plugins
241
+ end
242
+
243
+ def initialize(plugins, defaults)
244
+ @plugins = plugins
245
+ @defaults = defaults
246
+ end
247
+
248
+ def method_missing(m, *args)
249
+ plugin = Plugins.load_plugin(m)
250
+ @plugins << plugin
251
+ plugin.configure_default(@defaults, m, *args)
252
+ end
253
+ end
254
+ end
255
+ private_constant :DependencyResolver
256
+
257
+ class DependencyConflict < Mobility::Error; end
258
+ class CyclicDependency < DependencyConflict; end
259
+ end
260
+ end