mobility 0.8.10 → 1.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
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