mobility 0.6.0 → 0.7.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 (66) hide show
  1. checksums.yaml +5 -5
  2. checksums.yaml.gz.sig +3 -2
  3. data.tar.gz.sig +0 -0
  4. data/CHANGELOG.md +39 -1
  5. data/Gemfile.lock +65 -10
  6. data/README.md +63 -27
  7. data/lib/mobility.rb +16 -31
  8. data/lib/mobility/active_record.rb +2 -12
  9. data/lib/mobility/active_record/uniqueness_validator.rb +9 -8
  10. data/lib/mobility/arel.rb +20 -0
  11. data/lib/mobility/arel/nodes.rb +16 -0
  12. data/lib/mobility/arel/nodes/pg_ops.rb +136 -0
  13. data/lib/mobility/arel/visitor.rb +61 -0
  14. data/lib/mobility/attributes.rb +82 -19
  15. data/lib/mobility/backend.rb +53 -8
  16. data/lib/mobility/backend_resetter.rb +2 -1
  17. data/lib/mobility/backends/active_record.rb +31 -11
  18. data/lib/mobility/backends/active_record/column.rb +7 -3
  19. data/lib/mobility/backends/active_record/container.rb +23 -21
  20. data/lib/mobility/backends/active_record/hstore.rb +11 -6
  21. data/lib/mobility/backends/active_record/json.rb +22 -16
  22. data/lib/mobility/backends/active_record/jsonb.rb +22 -16
  23. data/lib/mobility/backends/active_record/key_value.rb +123 -15
  24. data/lib/mobility/backends/active_record/pg_hash.rb +1 -2
  25. data/lib/mobility/backends/active_record/serialized.rb +7 -6
  26. data/lib/mobility/backends/active_record/table.rb +145 -24
  27. data/lib/mobility/backends/hash_valued.rb +15 -10
  28. data/lib/mobility/backends/key_value.rb +12 -12
  29. data/lib/mobility/backends/sequel/container.rb +3 -9
  30. data/lib/mobility/backends/sequel/hstore.rb +2 -2
  31. data/lib/mobility/backends/sequel/json.rb +15 -15
  32. data/lib/mobility/backends/sequel/jsonb.rb +14 -14
  33. data/lib/mobility/backends/sequel/key_value.rb +0 -11
  34. data/lib/mobility/backends/sequel/pg_hash.rb +2 -3
  35. data/lib/mobility/backends/sequel/pg_query_methods.rb +1 -1
  36. data/lib/mobility/backends/sequel/query_methods.rb +3 -3
  37. data/lib/mobility/backends/sequel/serialized.rb +2 -2
  38. data/lib/mobility/backends/sequel/table.rb +10 -11
  39. data/lib/mobility/backends/table.rb +17 -8
  40. data/lib/mobility/configuration.rb +4 -1
  41. data/lib/mobility/interface.rb +0 -0
  42. data/lib/mobility/plugins.rb +1 -0
  43. data/lib/mobility/plugins/active_record/query.rb +192 -0
  44. data/lib/mobility/plugins/cache.rb +1 -2
  45. data/lib/mobility/plugins/default.rb +28 -14
  46. data/lib/mobility/plugins/fallbacks.rb +1 -1
  47. data/lib/mobility/plugins/locale_accessors.rb +13 -9
  48. data/lib/mobility/plugins/presence.rb +15 -7
  49. data/lib/mobility/plugins/query.rb +28 -0
  50. data/lib/mobility/translates.rb +9 -9
  51. data/lib/mobility/version.rb +1 -1
  52. data/lib/rails/generators/mobility/templates/initializer.rb +1 -0
  53. metadata +10 -15
  54. metadata.gz.sig +0 -0
  55. data/lib/mobility/accumulator.rb +0 -33
  56. data/lib/mobility/adapter.rb +0 -20
  57. data/lib/mobility/backends/active_record/column/query_methods.rb +0 -42
  58. data/lib/mobility/backends/active_record/container/json_query_methods.rb +0 -36
  59. data/lib/mobility/backends/active_record/container/jsonb_query_methods.rb +0 -33
  60. data/lib/mobility/backends/active_record/hstore/query_methods.rb +0 -25
  61. data/lib/mobility/backends/active_record/json/query_methods.rb +0 -30
  62. data/lib/mobility/backends/active_record/jsonb/query_methods.rb +0 -26
  63. data/lib/mobility/backends/active_record/key_value/query_methods.rb +0 -76
  64. data/lib/mobility/backends/active_record/pg_query_methods.rb +0 -154
  65. data/lib/mobility/backends/active_record/serialized/query_methods.rb +0 -34
  66. data/lib/mobility/backends/active_record/table/query_methods.rb +0 -105
@@ -25,17 +25,6 @@ Implements the {Mobility::Backends::KeyValue} backend for Sequel models.
25
25
 
26
26
  require 'mobility/backends/sequel/key_value/query_methods'
27
27
 
28
- # @return [Class] translation model class
29
- attr_reader :class_name
30
-
31
- # @!macro backend_constructor
32
- # @option options [Symbol] association_name Name of association
33
- # @option options [Class] class_name Translation model class
34
- def initialize(model, attribute, options = {})
35
- super
36
- @class_name = options[:class_name]
37
- end
38
-
39
28
  # @!group Backend Configuration
40
29
  # @option (see Mobility::Backends::KeyValue::ClassMethods#configure)
41
30
  # @raise (see Mobility::Backends::KeyValue::ClassMethods#configure)
@@ -35,8 +35,7 @@ jsonb).
35
35
  end
36
36
 
37
37
  setup do |attributes, options|
38
- column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
39
- columns = attributes.map { |attribute| (column_affix % attribute).to_sym }
38
+ columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym }
40
39
 
41
40
  before_validation = Module.new do
42
41
  define_method :before_validation do
@@ -48,7 +47,7 @@ jsonb).
48
47
  end
49
48
  include before_validation
50
49
  include Mobility::Sequel::HashInitializer.new(*columns)
51
- include Mobility::Sequel::ColumnChanges.new(attributes, column_affix: column_affix)
50
+ include Mobility::Sequel::ColumnChanges.new(attributes, column_affix: options[:column_affix])
52
51
 
53
52
  plugin :defaults_setter
54
53
  columns.each { |column| default_values[column] = {} }
@@ -30,7 +30,7 @@ for hstore/json/jsonb/container backends.)
30
30
 
31
31
  def initialize(attributes, options)
32
32
  super
33
- @column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
33
+ @column_affix = options[:column_affix]
34
34
  define_query_methods
35
35
  end
36
36
 
@@ -13,11 +13,11 @@ models. For details see backend-specific subclasses.
13
13
  class QueryMethods < Module
14
14
  # @param [Array<String>] attributes Translated attributes
15
15
  def initialize(attributes, _)
16
- @attributes = attributes.map!(&:to_sym)
16
+ @attributes = attributes.map(&:to_sym)
17
17
 
18
- attributes.each do |attribute|
18
+ @attributes.each do |attribute|
19
19
  define_method :"first_by_#{attribute}" do |value|
20
- where(attribute.to_sym => value).select_all(model.table_name).first
20
+ where(attribute => value).select_all(model.table_name).first
21
21
  end
22
22
  end
23
23
  end
@@ -43,14 +43,14 @@ Sequel serialization plugin.
43
43
  # @option (see Backends::Serialized.configure)
44
44
  # @raise (see Backends::Serialized.configure)
45
45
  def self.configure(options)
46
+ super
46
47
  Serialized.configure(options)
47
48
  end
48
49
  # @!endgroup
49
50
 
50
51
  setup do |attributes, options|
51
52
  format = options[:format]
52
- column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
53
- columns = attributes.map { |attribute| (column_affix % attribute).to_sym }
53
+ columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym }
54
54
 
55
55
  plugin :serialization
56
56
  plugin :serialization_modification_detection
@@ -14,17 +14,16 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
14
14
  class Sequel::Table
15
15
  include Sequel
16
16
  include Table
17
- include Util
18
17
 
19
18
  require 'mobility/backends/sequel/table/query_methods'
20
19
 
21
- # @return [Symbol] class for translations
22
- attr_reader :translation_class
20
+ def translation_class
21
+ self.class.translation_class
22
+ end
23
23
 
24
- # @!macro backend_constructor
25
- def initialize(model, attribute, options = {})
26
- super
27
- @translation_class = options[:model_class].const_get(options[:subclass_name])
24
+ # @return [Symbol] class for translations
25
+ def self.translation_class
26
+ @translation_class ||= model_class.const_get(subclass_name)
28
27
  end
29
28
 
30
29
  # @!group Backend Configuration
@@ -35,11 +34,11 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
35
34
  # @raise [CacheRequired] if cache option is false
36
35
  def self.configure(options)
37
36
  raise CacheRequired, "Cache required for Sequel::Table backend" if options[:cache] == false
38
- table_name = options[:model_class].table_name
39
- options[:table_name] ||= :"#{singularize(table_name)}_translations"
40
- options[:foreign_key] ||= foreign_key(camelize(singularize(table_name.to_s.downcase)))
37
+ table_name = Util.singularize(options[:model_class].table_name)
38
+ options[:table_name] ||= :"#{table_name}_translations"
39
+ options[:foreign_key] ||= Util.foreign_key(Util.camelize(table_name.downcase))
41
40
  if association_name = options[:association_name]
42
- options[:subclass_name] ||= camelize(singularize(association_name))
41
+ options[:subclass_name] ||= Util.camelize(Util.singularize(association_name))
43
42
  else
44
43
  options[:association_name] = :translations
45
44
  options[:subclass_name] ||= :Translation
@@ -66,16 +66,21 @@ set.
66
66
  =end
67
67
  module Table
68
68
  extend Backend::OrmDelegator
69
+ # @!method association_name
70
+ # Returns the name of the translations association.
71
+ # @return [Symbol] Name of the association
69
72
 
70
- # @return [Symbol] name of the association method
71
- attr_reader :association_name
73
+ # @!method subclass_name
74
+ # Returns translation subclass under model class namespace.
75
+ # @return [Symbol] Name of translation subclass
72
76
 
73
- # @!macro backend_constructor
74
- # @option options [Symbol] association_name Name of association
75
- def initialize(model, attribute, options = {})
76
- super
77
- @association_name = options[:association_name]
78
- end
77
+ # @!method foreign_key
78
+ # Returns foreign_key for translations association.
79
+ # @return [Symbol] Name of foreign key
80
+
81
+ # @!method table_name
82
+ # Returns name of table where translations are stored.
83
+ # @return [Symbol] Name of translations table
79
84
 
80
85
  # @!group Backend Accessors
81
86
  # @!macro backend_reader
@@ -102,6 +107,10 @@ set.
102
107
 
103
108
  def self.included(backend)
104
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
105
114
  end
106
115
 
107
116
  module ClassMethods
@@ -106,10 +106,13 @@ default_fallbacks= will be removed in the next major version of Mobility.
106
106
  @fallbacks_generator = lambda { |fallbacks| Mobility::Fallbacks.build(fallbacks) }
107
107
  @default_accessor_locales = lambda { I18n.available_locales }
108
108
  @default_options = Options[{
109
- cache: true,
109
+ cache: true,
110
110
  presence: true,
111
+ query: true,
112
+ default: Plugins::OPTION_UNSET
111
113
  }]
112
114
  @plugins = %i[
115
+ query
113
116
  cache
114
117
  dirty
115
118
  fallbacks
File without changes
@@ -31,5 +31,6 @@ option value. For examples, see classes under the {Mobility::Plugins} namespace.
31
31
 
32
32
  =end
33
33
  module Plugins
34
+ OPTION_UNSET = Object.new
34
35
  end
35
36
  end
@@ -0,0 +1,192 @@
1
+ # frozen-string-literal: true
2
+ module Mobility
3
+ module Plugins
4
+ =begin
5
+
6
+ Adds a scope which enables querying on translated attributes using +where+ and
7
+ +not+ as if they were normal attributes. Under the hood, this plugin uses the
8
+ generic +build_node+ and +apply_scope+ methods implemented in each backend
9
+ class to build ActiveRecord queries from Arel nodes. The plugin also adds
10
+ +find_by_<attribute>+ shortcuts for translated attributes.
11
+
12
+ The query scope applies to all translated attributes once the plugin has been
13
+ enabled for any one attribute on the model.
14
+
15
+ =end
16
+ module ActiveRecord
17
+ module Query
18
+ class << self
19
+ def apply(attributes)
20
+ model_class = attributes.model_class
21
+
22
+ model_class.class_eval do
23
+ extend QueryMethod
24
+ extend FindByMethods.new(*attributes.names)
25
+ singleton_class.send :alias_method, Mobility.query_method, :__mobility_query_scope__
26
+ end
27
+ end
28
+ end
29
+
30
+ module QueryMethod
31
+ def __mobility_query_scope__(locale: Mobility.locale, &block)
32
+ if block_given?
33
+ VirtualRow.build_query(self, locale, &block)
34
+ else
35
+ all.extending(QueryExtension)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Internal class to create a "clean room" for manipulating translated
41
+ # attribute nodes in an instance-eval'ed block. Inspired by Sequel's
42
+ # (much more sophisticated) virtual rows.
43
+ class VirtualRow < BasicObject
44
+ attr_reader :__backends
45
+
46
+ def initialize(model_class, locale)
47
+ @model_class, @locale, @__backends = model_class, locale, []
48
+ end
49
+
50
+ def method_missing(m, *)
51
+ if @model_class.mobility_attributes.include?(m.to_s)
52
+ @__backends |= [@model_class.mobility_backend_class(m)]
53
+ @model_class.mobility_backend_class(m).build_node(m, @locale)
54
+ elsif @model_class.column_names.include?(m.to_s)
55
+ @model_class.arel_table[m]
56
+ else
57
+ super
58
+ end
59
+ end
60
+
61
+ class << self
62
+ def build_query(klass, locale, &block)
63
+ row = new(klass, locale)
64
+ query = block.arity.zero? ? row.instance_eval(&block) : block.call(row)
65
+
66
+ if ::ActiveRecord::Relation === query
67
+ predicates = query.arel.constraints
68
+ apply_scopes(klass.all, row.__backends, locale, predicates).merge(query)
69
+ else
70
+ apply_scopes(klass.all, row.__backends, locale, query).where(query)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def apply_scopes(scope, backends, locale, predicates)
77
+ backends.inject(scope) { |r, b| b.apply_scope(r, predicates, locale) }
78
+ end
79
+ end
80
+ end
81
+ private_constant :QueryMethod, :VirtualRow
82
+
83
+ module QueryExtension
84
+ def where!(opts, *rest)
85
+ QueryBuilder.build(self, opts) do |untranslated_opts|
86
+ untranslated_opts ? super(untranslated_opts, *rest) : super
87
+ end
88
+ end
89
+
90
+ def where(opts = :chain, *rest)
91
+ opts == :chain ? WhereChain.new(spawn) : super
92
+ end
93
+
94
+ # Return backend node for attribute name.
95
+ # @param [Symbol,String] name Name of attribute
96
+ # @param [Symbol] locale Locale
97
+ # @return [Arel::Node] Arel node for this attribute in given locale
98
+ def backend_node(name, locale = Mobility.locale)
99
+ @klass.mobility_backend_class(name)[name, locale]
100
+ end
101
+
102
+ class WhereChain < ::ActiveRecord::QueryMethods::WhereChain
103
+ def not(opts, *rest)
104
+ QueryBuilder.build(@scope, opts, invert: true) do |untranslated_opts|
105
+ untranslated_opts ? super(untranslated_opts, *rest) : super
106
+ end
107
+ end
108
+ end
109
+
110
+ module QueryBuilder
111
+ class << self
112
+ def build(scope, where_opts, invert: false)
113
+ return yield unless Hash === where_opts
114
+
115
+ opts = where_opts.with_indifferent_access
116
+ locale = opts.delete(:locale) || Mobility.locale
117
+
118
+ maps = build_maps!(scope, opts, locale, invert: invert)
119
+ return yield if maps.empty?
120
+
121
+ base = opts.empty? ? scope : yield(opts)
122
+ maps.inject(base) { |rel, map| map[rel] }
123
+ end
124
+
125
+ private
126
+
127
+ def build_maps!(scope, opts, locale, invert:)
128
+ keys = opts.keys.map(&:to_s)
129
+
130
+ scope.mobility_modules.map { |mod|
131
+ next if (i18n_keys = mod.names & keys).empty?
132
+
133
+ predicates = i18n_keys.map do |key|
134
+ build_predicate(scope.backend_node(key.to_sym, locale), opts.delete(key))
135
+ end
136
+
137
+ ->(relation) do
138
+ relation = mod.backend_class.apply_scope(relation, predicates, locale, invert: invert)
139
+ predicates = predicates.map(&method(:invert_predicate)) if invert
140
+ relation.where(predicates.inject(&:and))
141
+ end
142
+ }.compact
143
+ end
144
+
145
+ def build_predicate(node, values)
146
+ nils, vals = partition_values(values)
147
+
148
+ return node.eq(nil) if vals.empty?
149
+
150
+ predicate = vals.length == 1 ? node.eq(vals.first) : node.in(vals)
151
+ predicate = predicate.or(node.eq(nil)) unless nils.empty?
152
+ predicate
153
+ end
154
+
155
+ def partition_values(values)
156
+ Array.wrap(values).uniq.partition(&:nil?)
157
+ end
158
+
159
+ # Adapted from AR::Relation::WhereClause#invert_predicate
160
+ def invert_predicate(node)
161
+ case node
162
+ when ::Arel::Nodes::In
163
+ ::Arel::Nodes::NotIn.new(node.left, node.right)
164
+ when ::Arel::Nodes::Equality
165
+ ::Arel::Nodes::NotEqual.new(node.left, node.right)
166
+ else
167
+ ::Arel::Nodes::Not.new(node)
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ private_constant :WhereChain, :QueryBuilder
174
+ end
175
+
176
+ class FindByMethods < Module
177
+ def initialize(*attributes)
178
+ attributes.each do |attribute|
179
+ module_eval <<-EOM, __FILE__, __LINE__ + 1
180
+ def find_by_#{attribute}(value)
181
+ find_by(#{attribute}: value)
182
+ end
183
+ EOM
184
+ end
185
+ end
186
+ end
187
+
188
+ private_constant :QueryExtension, :FindByMethods
189
+ end
190
+ end
191
+ end
192
+ end
@@ -37,9 +37,8 @@ Values are added to the cache in two ways:
37
37
  # @group Backend Accessors
38
38
  #
39
39
  # @!macro backend_reader
40
- # @option options [Boolean] cache
41
- # *false* to disable cache.
42
40
  # @!method read(locale, value, options = {})
41
+ # @option options [Boolean] cache *false* to disable cache.
43
42
  include TranslationCacher.new(:read)
44
43
 
45
44
  # @!macro backend_writer
@@ -5,7 +5,9 @@ module Mobility
5
5
  =begin
6
6
 
7
7
  Defines value or proc to fall through to if return value from getter would
8
- otherwise be nil.
8
+ otherwise be nil. This plugin is disabled by default but will be enabled if any
9
+ value (other than +Mobility::Plugins::OPTION_UNSET+) is passed as the +default+
10
+ option key.
9
11
 
10
12
  If default is a +Proc+, it will be called with the context of the model, and
11
13
  passed arguments:
@@ -60,27 +62,39 @@ The proc can accept zero to three arguments (see examples below)
60
62
  post.title(default: lambda { self.class.name.to_s })
61
63
  #=> "Post"
62
64
  =end
63
- class Default < Module
65
+ module Default
64
66
  # Applies default plugin to attributes.
65
67
  # @param [Attributes] attributes
66
68
  # @param [Object] option
67
69
  def self.apply(attributes, option)
68
- attributes.backend_class.include(new(option))
70
+ attributes.backend_class.include(self) unless option == Plugins::OPTION_UNSET
69
71
  end
70
72
 
71
- def initialize(default_option)
72
- define_method :read do |locale, options = {}|
73
- default = options.has_key?(:default) ? options.delete(:default) : default_option
74
- if (value = super(locale, options)).nil?
75
- return default unless default.is_a?(Proc)
76
- args = [attribute, locale, options]
77
- args = args.first(default.arity) unless default.arity < 0
78
- model.instance_exec(*args, &default)
79
- else
80
- value
81
- end
73
+ # Generate a default value for given parameters.
74
+ # @param [Object, Proc] default_value A default value or Proc
75
+ # @param [Symbol] locale
76
+ # @param [Hash] accessor_options
77
+ # @param [String] attribute
78
+ def self.[](default_value, locale:, accessor_options:, model:, attribute:)
79
+ return default_value unless default_value.is_a?(Proc)
80
+ args = [attribute, locale, accessor_options]
81
+ args = args.first(default_value.arity) unless default_value.arity < 0
82
+ model.instance_exec(*args, &default_value)
83
+ end
84
+
85
+ # @!group Backend Accessors
86
+ # @!macro backend_reader
87
+ # @option accessor_options [Boolean] default
88
+ # *false* to disable presence filter.
89
+ def read(locale, accessor_options = {})
90
+ default = accessor_options.has_key?(:default) ? accessor_options.delete(:default) : options[:default]
91
+ if (value = super(locale, accessor_options)).nil?
92
+ Default[default, locale: locale, accessor_options: accessor_options, model: model, attribute: attribute]
93
+ else
94
+ value
82
95
  end
83
96
  end
97
+ # @!endgroup
84
98
  end
85
99
  end
86
100
  end