mobility 0.8.10 → 1.0.0.beta2

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 (96) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/CHANGELOG.md +66 -0
  5. data/Gemfile +50 -18
  6. data/Gemfile.lock +36 -101
  7. data/README.md +183 -91
  8. data/Rakefile +6 -4
  9. data/lib/mobility.rb +44 -166
  10. data/lib/mobility/arel.rb +1 -1
  11. data/lib/mobility/arel/nodes/pg_ops.rb +1 -1
  12. data/lib/mobility/backend.rb +27 -51
  13. data/lib/mobility/backends.rb +20 -0
  14. data/lib/mobility/backends/active_record.rb +4 -0
  15. data/lib/mobility/backends/active_record/column.rb +2 -0
  16. data/lib/mobility/backends/active_record/container.rb +6 -7
  17. data/lib/mobility/backends/active_record/hstore.rb +3 -1
  18. data/lib/mobility/backends/active_record/json.rb +2 -0
  19. data/lib/mobility/backends/active_record/jsonb.rb +2 -0
  20. data/lib/mobility/backends/active_record/key_value.rb +6 -4
  21. data/lib/mobility/backends/active_record/pg_hash.rb +1 -1
  22. data/lib/mobility/backends/active_record/serialized.rb +6 -0
  23. data/lib/mobility/backends/active_record/table.rb +6 -4
  24. data/lib/mobility/backends/column.rb +0 -6
  25. data/lib/mobility/backends/container.rb +10 -1
  26. data/lib/mobility/backends/hash.rb +39 -0
  27. data/lib/mobility/backends/hash_valued.rb +4 -0
  28. data/lib/mobility/backends/hstore.rb +0 -1
  29. data/lib/mobility/backends/json.rb +0 -1
  30. data/lib/mobility/backends/jsonb.rb +1 -2
  31. data/lib/mobility/backends/key_value.rb +31 -26
  32. data/lib/mobility/backends/null.rb +2 -0
  33. data/lib/mobility/backends/sequel.rb +5 -2
  34. data/lib/mobility/backends/sequel/column.rb +2 -0
  35. data/lib/mobility/backends/sequel/container.rb +6 -6
  36. data/lib/mobility/backends/sequel/hstore.rb +3 -1
  37. data/lib/mobility/backends/sequel/json.rb +3 -0
  38. data/lib/mobility/backends/sequel/jsonb.rb +3 -1
  39. data/lib/mobility/backends/sequel/key_value.rb +8 -6
  40. data/lib/mobility/backends/sequel/serialized.rb +6 -0
  41. data/lib/mobility/backends/sequel/table.rb +5 -2
  42. data/lib/mobility/backends/serialized.rb +1 -3
  43. data/lib/mobility/backends/table.rb +29 -26
  44. data/lib/mobility/pluggable.rb +56 -0
  45. data/lib/mobility/plugin.rb +260 -0
  46. data/lib/mobility/plugins.rb +27 -24
  47. data/lib/mobility/plugins/active_model.rb +17 -0
  48. data/lib/mobility/plugins/active_model/cache.rb +26 -0
  49. data/lib/mobility/plugins/active_model/dirty.rb +119 -78
  50. data/lib/mobility/plugins/active_record.rb +34 -0
  51. data/lib/mobility/plugins/active_record/backend.rb +25 -0
  52. data/lib/mobility/plugins/active_record/cache.rb +28 -0
  53. data/lib/mobility/plugins/active_record/dirty.rb +34 -17
  54. data/lib/mobility/plugins/active_record/query.rb +48 -34
  55. data/lib/mobility/plugins/active_record/uniqueness_validation.rb +60 -0
  56. data/lib/mobility/plugins/attribute_methods.rb +29 -20
  57. data/lib/mobility/plugins/attributes.rb +72 -0
  58. data/lib/mobility/plugins/backend.rb +161 -0
  59. data/lib/mobility/plugins/backend_reader.rb +34 -0
  60. data/lib/mobility/plugins/cache.rb +68 -26
  61. data/lib/mobility/plugins/default.rb +22 -17
  62. data/lib/mobility/plugins/dirty.rb +12 -33
  63. data/lib/mobility/plugins/fallbacks.rb +52 -44
  64. data/lib/mobility/plugins/fallthrough_accessors.rb +25 -25
  65. data/lib/mobility/plugins/locale_accessors.rb +22 -35
  66. data/lib/mobility/plugins/presence.rb +28 -21
  67. data/lib/mobility/plugins/query.rb +8 -17
  68. data/lib/mobility/plugins/reader.rb +50 -0
  69. data/lib/mobility/plugins/sequel.rb +34 -0
  70. data/lib/mobility/plugins/sequel/backend.rb +25 -0
  71. data/lib/mobility/plugins/sequel/cache.rb +24 -0
  72. data/lib/mobility/plugins/sequel/dirty.rb +33 -22
  73. data/lib/mobility/plugins/sequel/query.rb +21 -6
  74. data/lib/mobility/plugins/writer.rb +44 -0
  75. data/lib/mobility/translations.rb +95 -0
  76. data/lib/mobility/version.rb +12 -1
  77. data/lib/rails/generators/mobility/templates/initializer.rb +96 -78
  78. metadata +28 -27
  79. metadata.gz.sig +0 -0
  80. data/lib/mobility/active_model.rb +0 -4
  81. data/lib/mobility/active_model/backend_resetter.rb +0 -26
  82. data/lib/mobility/active_record.rb +0 -23
  83. data/lib/mobility/active_record/backend_resetter.rb +0 -26
  84. data/lib/mobility/active_record/uniqueness_validator.rb +0 -60
  85. data/lib/mobility/attributes.rb +0 -324
  86. data/lib/mobility/backend/orm_delegator.rb +0 -44
  87. data/lib/mobility/backend_resetter.rb +0 -50
  88. data/lib/mobility/configuration.rb +0 -138
  89. data/lib/mobility/fallbacks.rb +0 -28
  90. data/lib/mobility/interface.rb +0 -0
  91. data/lib/mobility/loaded.rb +0 -4
  92. data/lib/mobility/plugins/active_record/attribute_methods.rb +0 -38
  93. data/lib/mobility/plugins/cache/translation_cacher.rb +0 -40
  94. data/lib/mobility/sequel.rb +0 -9
  95. data/lib/mobility/sequel/backend_resetter.rb +0 -23
  96. data/lib/mobility/translates.rb +0 -73
@@ -20,5 +20,7 @@ Backend which does absolutely nothing. Mostly for testing purposes.
20
20
  def self.configure(_); end
21
21
  # @!endgroup
22
22
  end
23
+
24
+ register_backend(:null, Null)
23
25
  end
24
26
  end
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+ require "mobility/backend"
3
+
1
4
  module Mobility
2
5
  module Backends
3
6
  module Sequel
4
7
  def self.included(backend_class)
5
- backend_class.include(Backend)
6
- backend_class.extend(ClassMethods)
8
+ backend_class.include Backend
9
+ backend_class.extend ClassMethods
7
10
  end
8
11
 
9
12
  module ClassMethods
@@ -50,5 +50,7 @@ Implements the {Mobility::Backends::Column} backend for Sequel models.
50
50
  end.compact
51
51
  end
52
52
  end
53
+
54
+ register_backend(:sequel_column, Sequel::Column)
53
55
  end
54
56
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
- require "mobility/backends/sequel/json"
2
+ require "mobility/backends/sequel"
3
3
  require "mobility/backends/sequel/jsonb"
4
+ require "mobility/backends/container"
4
5
 
5
6
  module Mobility
6
7
  module Backends
@@ -11,10 +12,7 @@ Implements the {Mobility::Backends::Container} backend for Sequel models.
11
12
  =end
12
13
  class Sequel::Container
13
14
  include Sequel
14
-
15
- # @!method column_name
16
- # @return [Symbol] (:translations) Name of translations column
17
- option_reader :column_name
15
+ include Container
18
16
 
19
17
  # @!group Backend Accessors
20
18
  #
@@ -44,7 +42,7 @@ Implements the {Mobility::Backends::Container} backend for Sequel models.
44
42
  def self.configure(options)
45
43
  options[:column_name] ||= :translations
46
44
  options[:column_name] = options[:column_name].to_sym
47
- column_name, db_schema = options[:column_name], options[:model_class].db_schema
45
+ column_name, db_schema = options[:column_name], model_class.db_schema
48
46
  options[:column_type] = db_schema[column_name] && (db_schema[column_name][:db_type]).to_sym
49
47
  unless %i[json jsonb].include?(options[:column_type])
50
48
  raise InvalidColumnType, "#{options[:column_name]} must be a column of type json or jsonb"
@@ -114,5 +112,7 @@ Implements the {Mobility::Backends::Container} backend for Sequel models.
114
112
  end
115
113
  end
116
114
  end
115
+
116
+ register_backend(:sequel_container, Sequel::Container)
117
117
  end
118
118
  end
@@ -20,7 +20,7 @@ Implements the {Mobility::Backends::Hstore} backend for Sequel models.
20
20
  # @!group Backend Accessors
21
21
  # @!macro backend_writer
22
22
  def write(locale, value, options = {})
23
- super(locale, value && value.to_s, options)
23
+ super(locale, value && value.to_s, **options)
24
24
  end
25
25
  # @!endgroup
26
26
 
@@ -35,5 +35,7 @@ Implements the {Mobility::Backends::Hstore} backend for Sequel models.
35
35
  class HStoreOp < ::Sequel::Postgres::HStoreOp; end
36
36
  end
37
37
  end
38
+
39
+ register_backend(:sequel_hstore, Sequel::Hstore)
38
40
  end
39
41
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'mobility/backends/sequel/pg_hash'
2
3
 
3
4
  Sequel.extension :pg_json, :pg_json_ops
@@ -42,5 +43,7 @@ Implements the {Mobility::Backends::Json} backend for Sequel models.
42
43
  class JSONOp < ::Sequel::Postgres::JSONOp; end
43
44
  end
44
45
  end
46
+
47
+ register_backend(:sequel_json, Sequel::Json)
45
48
  end
46
49
  end
@@ -55,7 +55,7 @@ Implements the {Mobility::Backends::Jsonb} backend for Sequel models.
55
55
 
56
56
  def =~(other)
57
57
  case other
58
- when Integer, Hash
58
+ when Integer, ::Hash
59
59
  to_dash_arrow =~ other.to_json
60
60
  when NilClass
61
61
  ~to_question
@@ -66,5 +66,7 @@ Implements the {Mobility::Backends::Jsonb} backend for Sequel models.
66
66
  end
67
67
  end
68
68
  end
69
+
70
+ register_backend(:sequel_jsonb, Sequel::Jsonb)
69
71
  end
70
72
  end
@@ -36,7 +36,7 @@ Implements the {Mobility::Backends::KeyValue} backend for Sequel models.
36
36
  options[:association_name] ||= :"#{options[:type]}_translations"
37
37
  options[:class_name] ||= Mobility::Sequel.const_get("#{type.capitalize}Translation")
38
38
  end
39
- options[:table_alias_affix] = "#{options[:model_class]}_%s_#{options[:association_name]}"
39
+ options[:table_alias_affix] = "#{model_class}_%s_#{options[:association_name]}"
40
40
  rescue NameError
41
41
  raise ArgumentError, "You must define a Mobility::Sequel::#{type.capitalize}Translation class."
42
42
  end
@@ -93,7 +93,7 @@ Implements the {Mobility::Backends::KeyValue} backend for Sequel models.
93
93
  join_type = nils.empty? ? :inner : :left_outer
94
94
  # TODO: simplify to hash.transform_values { join_type } when
95
95
  # support for Ruby 2.3 is deprecated
96
- Hash[hash.keys.map { |key| [key, join_type] }]
96
+ ::Hash[hash.keys.map { |key| [key, join_type] }]
97
97
  else
98
98
  {}
99
99
  end
@@ -101,13 +101,13 @@ Implements the {Mobility::Backends::KeyValue} backend for Sequel models.
101
101
  hash = visit(boolean.args, locale)
102
102
  # TODO: simplify to hash.transform_values { :inner } when
103
103
  # support for Ruby 2.3 is deprecated
104
- Hash[hash.keys.map { |key| [key, :inner] }]
104
+ ::Hash[hash.keys.map { |key| [key, :inner] }]
105
105
  elsif boolean.op == :OR
106
106
  hash = boolean.args.map { |op| visit(op, locale) }.
107
- compact.inject(&:merge)
107
+ compact.inject(:merge)
108
108
  # TODO: simplify to hash.transform_values { :left_outer } when
109
109
  # support for Ruby 2.3 is deprecated
110
- Hash[hash.keys.map { |key| [key, :left_outer] }]
110
+ ::Hash[hash.keys.map { |key| [key, :left_outer] }]
111
111
  else
112
112
  visit(boolean.args, locale)
113
113
  end
@@ -153,7 +153,7 @@ Implements the {Mobility::Backends::KeyValue} backend for Sequel models.
153
153
  end
154
154
  define_method :after_save do
155
155
  super()
156
- attributes.each { |attribute| public_send(Backend.method_name(attribute)).save_translations }
156
+ attributes.each { |attribute| mobility_backends[attribute].save_translations }
157
157
  end
158
158
  end
159
159
  include callback_methods
@@ -202,5 +202,7 @@ Implements the {Mobility::Backends::KeyValue} backend for Sequel models.
202
202
  end
203
203
  end
204
204
  end
205
+
206
+ register_backend(:sequel_key_value, Sequel::KeyValue)
205
207
  end
206
208
  end
@@ -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,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  require "mobility/util"
3
3
  require "mobility/backends/sequel"
4
- require "mobility/backends/key_value"
4
+ require "mobility/backends/table"
5
+ require "mobility/sequel/column_changes"
5
6
  require "mobility/sequel/model_translation"
6
7
  require "mobility/sequel/sql"
7
8
 
@@ -34,7 +35,7 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
34
35
  # @raise [CacheRequired] if cache option is false
35
36
  def configure(options)
36
37
  raise CacheRequired, "Cache required for Sequel::Table backend" if options[:cache] == false
37
- table_name = Util.singularize(options[:model_class].table_name)
38
+ table_name = Util.singularize(model_class.table_name)
38
39
  options[:table_name] ||= :"#{table_name}_translations"
39
40
  options[:foreign_key] ||= Util.foreign_key(Util.camelize(table_name.downcase))
40
41
  if association_name = options[:association_name]
@@ -175,5 +176,7 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
175
176
 
176
177
  class CacheRequired < ::StandardError; end
177
178
  end
179
+
180
+ register_backend(:sequel_table, Sequel::Table)
178
181
  end
179
182
  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
@@ -65,7 +65,6 @@ set.
65
65
  @see Mobility::Backends::Sequel::Table
66
66
  =end
67
67
  module Table
68
- extend Backend::OrmDelegator
69
68
  # @!method association_name
70
69
  # Returns the name of the translations association.
71
70
  # @return [Symbol] Name of the association
@@ -84,13 +83,13 @@ set.
84
83
 
85
84
  # @!group Backend Accessors
86
85
  # @!macro backend_reader
87
- def read(locale, options = {})
88
- translation_for(locale, options).send(attribute)
86
+ def read(locale, **options)
87
+ translation_for(locale, **options).send(attribute)
89
88
  end
90
89
 
91
90
  # @!macro backend_writer
92
- def write(locale, value, options = {})
93
- translation_for(locale, options).send("#{attribute}=", value)
91
+ def write(locale, value, **options)
92
+ translation_for(locale, **options).send("#{attribute}=", value)
94
93
  end
95
94
  # @!endgroup
96
95
 
@@ -105,25 +104,22 @@ set.
105
104
  model.send(association_name)
106
105
  end
107
106
 
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
107
+ def self.included(backend_class)
108
+ backend_class.extend ClassMethods
109
+ backend_class.option_reader :association_name
110
+ backend_class.option_reader :subclass_name
111
+ backend_class.option_reader :foreign_key
112
+ backend_class.option_reader :table_name
114
113
  end
115
114
 
116
115
  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
116
+ def valid_keys
117
+ [:association_name, :subclass_name, :foreign_key, :table_name]
118
+ end
119
+
120
+ # Apply custom processing for cache plugin
121
+ def include_cache
122
+ include self::Cache
127
123
  end
128
124
 
129
125
  def table_alias(locale)
@@ -134,7 +130,18 @@ set.
134
130
  # Simple hash cache to memoize translations as a hash so they can be
135
131
  # fetched quickly.
136
132
  module Cache
137
- include Plugins::Cache::TranslationCacher.new(:translation_for)
133
+ def translation_for(locale, **options)
134
+ return super(locale, options) if options.delete(:cache) == false
135
+ if cache.has_key?(locale)
136
+ cache[locale]
137
+ else
138
+ cache[locale] = super(locale, **options)
139
+ end
140
+ end
141
+
142
+ def clear_cache
143
+ model_cache && model_cache.clear
144
+ end
138
145
 
139
146
  private
140
147
 
@@ -145,10 +152,6 @@ set.
145
152
  def model_cache
146
153
  model.instance_variable_get(:"@__mobility_#{association_name}_cache")
147
154
  end
148
-
149
- def clear_cache
150
- model_cache && model_cache.clear
151
- end
152
155
  end
153
156
  end
154
157
  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