mobility 0.8.13 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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