mobility 0.6.0 → 0.7.0

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