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,8 +25,7 @@ Internal class used by ActiveRecord backends backed by a Postgres data type
25
25
  end
26
26
 
27
27
  setup do |attributes, options = {}|
28
- affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
29
- attributes.each { |attribute| store (affix % attribute), coder: Coder }
28
+ attributes.each { |attribute| store (options[:column_affix] % attribute), coder: Coder }
30
29
  end
31
30
 
32
31
  class Coder
@@ -30,25 +30,26 @@ Implements {Mobility::Backends::Serialized} backend for ActiveRecord models.
30
30
  include ActiveRecord
31
31
  include HashValued
32
32
 
33
- require 'mobility/backends/active_record/serialized/query_methods'
34
-
35
33
  # @!group Backend Configuration
36
34
  # @param (see Backends::Serialized.configure)
37
35
  # @option (see Backends::Serialized.configure)
38
36
  # @raise (see Backends::Serialized.configure)
39
37
  def self.configure(options)
38
+ super
40
39
  Serialized.configure(options)
41
40
  end
42
41
  # @!endgroup
43
42
 
43
+ def self.build_node(attr, _locale)
44
+ raise ArgumentError,
45
+ "You cannot query on mobility attributes translated with the Serialized backend (#{attr})."
46
+ end
47
+
44
48
  setup do |attributes, options|
45
49
  coder = { yaml: YAMLCoder, json: JSONCoder }[options[:format]]
46
- column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
47
- attributes.each { |attribute| serialize (column_affix % attribute), coder }
50
+ attributes.each { |attribute| serialize (options[:column_affix] % attribute), coder }
48
51
  end
49
52
 
50
- setup_query_methods(QueryMethods)
51
-
52
53
  # @!group Cache Methods
53
54
  # Returns column value as a hash
54
55
  # @return [Hash]
@@ -19,7 +19,7 @@ If the translation table already exists, it will create a migration adding
19
19
  columns to that table.
20
20
 
21
21
  @example Model with table backend
22
- class Post < ActiveRecord::Base
22
+ class Post < ApplicationRecord
23
23
  extend Mobility
24
24
  translates :title, backend: :table
25
25
  end
@@ -92,28 +92,151 @@ columns to that table.
92
92
  include ActiveRecord
93
93
  include Table
94
94
 
95
- require 'mobility/backends/active_record/table/query_methods'
96
-
97
- # @!group Backend Configuration
98
- # @option options [Symbol] association_name (:translations)
99
- # Name of association method
100
- # @option options [Symbol] table_name Name of translation table
101
- # @option options [Symbol] foreign_key Name of foreign key
102
- # @option options [Symbol] subclass_name (:Translation) Name of subclass
103
- # to append to model class to generate translation class
104
- def self.configure(options)
105
- table_name = options[:model_class].table_name
106
- options[:table_name] ||= "#{table_name.singularize}_translations"
107
- options[:foreign_key] ||= table_name.downcase.singularize.camelize.foreign_key
108
- if (association_name = options[:association_name]).present?
109
- options[:subclass_name] ||= association_name.to_s.singularize.camelize.freeze
110
- else
111
- options[:association_name] = :translations
112
- options[:subclass_name] ||= :Translation
95
+ class << self
96
+ # @!group Backend Configuration
97
+ # @option options [Symbol] association_name (:translations)
98
+ # Name of association method
99
+ # @option options [Symbol] table_name Name of translation table
100
+ # @option options [Symbol] foreign_key Name of foreign key
101
+ # @option options [Symbol] subclass_name (:Translation) Name of subclass
102
+ # to append to model class to generate translation class
103
+ def configure(options)
104
+ table_name = options[:model_class].table_name
105
+ options[:table_name] ||= "#{table_name.singularize}_translations"
106
+ options[:foreign_key] ||= table_name.downcase.singularize.camelize.foreign_key
107
+ if (association_name = options[:association_name]).present?
108
+ options[:subclass_name] ||= association_name.to_s.singularize.camelize.freeze
109
+ else
110
+ options[:association_name] = :translations
111
+ options[:subclass_name] ||= :Translation
112
+ end
113
+ %i[foreign_key association_name subclass_name table_name].each { |key| options[key] = options[key].to_sym }
114
+ end
115
+ # @!endgroup
116
+
117
+ # @param [String] attr Attribute name
118
+ # @param [Symbol] _locale Locale
119
+ # @return [Mobility::Arel::Attribute] Arel node for column on translation table
120
+ def build_node(attr, locale)
121
+ aliased_table = model_class.const_get(subclass_name).arel_table.alias(table_alias(locale))
122
+ Arel::Attribute.new(aliased_table, attr, locale, self)
123
+ end
124
+
125
+ # Joins translations using either INNER/OUTER join appropriate to the
126
+ # query.
127
+ # @param [ActiveRecord::Relation] relation Relation to scope
128
+ # @param [Object] predicate Arel predicate
129
+ # @param [Symbol] locale Locale
130
+ # @option [Boolean] invert
131
+ # @return [ActiveRecord::Relation] relation Relation with joins applied (if needed)
132
+ def apply_scope(relation, predicate, locale, invert: false)
133
+ visitor = Visitor.new(self, locale)
134
+ if join_type = visitor.accept(predicate)
135
+ join_type &&= Visitor::INNER_JOIN if invert
136
+ join_translations(relation, locale, join_type)
137
+ else
138
+ relation
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def table_alias(locale)
145
+ "#{locale}_#{table_name}"
146
+ end
147
+
148
+ def join_translations(relation, locale, join_type)
149
+ return relation if already_joined?(relation, locale, join_type)
150
+ m = model_class.arel_table
151
+ t = model_class.const_get(subclass_name).arel_table.alias(table_alias(locale))
152
+ relation.joins(m.join(t, join_type).
153
+ on(t[foreign_key].eq(m[:id]).
154
+ and(t[:locale].eq(locale))).join_sources)
155
+ end
156
+
157
+ def already_joined?(relation, locale, join_type)
158
+ if join = get_join(relation, locale)
159
+ return true if (join_type == Visitor::OUTER_JOIN) || (Visitor::INNER_JOIN === join)
160
+ relation.joins_values = relation.joins_values - [join]
161
+ end
162
+ false
163
+ end
164
+
165
+ def get_join(relation, locale)
166
+ relation.joins_values.find { |v| (::Arel::Nodes::Join === v) && (v.left.name == table_alias(locale).to_s) }
167
+ end
168
+ end
169
+
170
+ # Internal class used to visit all nodes in a predicate clause and
171
+ # return a single join type required for the predicate, or nil if no
172
+ # join is required. (Similar to the KeyValue Visitor class.)
173
+ #
174
+ # Example:
175
+ #
176
+ # class Post < ApplicationRecord
177
+ # extend Mobility
178
+ # translates :title, :content, backend: :table
179
+ # end
180
+ #
181
+ # backend_class = Post.mobility_backend_class(:title)
182
+ # visitor = Mobility::Backends::ActiveRecord::Table::Visitor.new(backend_class)
183
+ #
184
+ # visitor.accept(title.eq(nil).and(content.eq(nil)))
185
+ # #=> Arel::Nodes::OuterJoin
186
+ #
187
+ # visitor.accept(title.eq("foo").and(content.eq(nil)))
188
+ # #=> Arel::Nodes::InnerJoin
189
+ #
190
+ # In the first case, both attributes are matched against nil values, so
191
+ # we need an OUTER JOIN. In the second case, one attribute is matched
192
+ # against a non-nil value, so we can use an INNER JOIN.
193
+ #
194
+ class Visitor < Arel::Visitor
195
+ private
196
+
197
+ def visit_Arel_Nodes_Equality(object)
198
+ nils, nodes = [object.left, object.right].partition(&:nil?)
199
+ if nodes.any?(&method(:visit))
200
+ nils.empty? ? INNER_JOIN : OUTER_JOIN
201
+ end
202
+ end
203
+
204
+ def visit_collection(objects)
205
+ objects.map { |obj|
206
+ visit(obj).tap { |visited| return visited if visited == INNER_JOIN }
207
+ }.compact.first
208
+ end
209
+ alias :visit_Array :visit_collection
210
+
211
+ # If either left or right is an OUTER JOIN (predicate with a NULL
212
+ # argument) OR we are combining this with anything other than a
213
+ # column on the same translation table, we need to OUTER JOIN
214
+ # here. The *only* case where we can use an INNER JOIN is when we
215
+ # have predicates like this:
216
+ #
217
+ # table.attribute1 = 'something' OR table.attribute2 = 'somethingelse'
218
+ #
219
+ # Here, both columns are on the same table, and both are non-nil, so
220
+ # we can safely INNER JOIN. This is pretty subtle, think about it.
221
+ #
222
+ def visit_Arel_Nodes_Or(object)
223
+ visited = [object.left, object.right].map(&method(:visit))
224
+ if visited.all? { |v| INNER_JOIN == v }
225
+ INNER_JOIN
226
+ elsif visited.any?
227
+ OUTER_JOIN
228
+ end
229
+ end
230
+
231
+ def visit_Mobility_Arel_Attribute(object)
232
+ # We compare table names here to ensure that attributes defined on
233
+ # different backends but the same table will correctly get an OUTER
234
+ # join when required. Use options[:table_name] here since we don't
235
+ # know if the other backend has a +table_name+ option accessor.
236
+ (backend_class.table_name == object.backend_class.options[:table_name]) &&
237
+ (locale == object.locale) && INNER_JOIN
113
238
  end
114
- %i[foreign_key association_name subclass_name table_name].each { |key| options[key] = options[key].to_sym }
115
239
  end
116
- # @!endgroup
117
240
 
118
241
  setup do |_attributes, options|
119
242
  association_name = options[:association_name]
@@ -143,7 +266,7 @@ columns to that table.
143
266
  touch: true
144
267
 
145
268
  before_save do
146
- required_attributes = self.class.translated_attribute_names & translation_class.attribute_names
269
+ required_attributes = self.class.mobility_attributes & translation_class.attribute_names
147
270
  send(association_name).destroy_empty_translations(required_attributes)
148
271
  end
149
272
 
@@ -159,8 +282,6 @@ columns to that table.
159
282
  end
160
283
  end
161
284
 
162
- setup_query_methods(QueryMethods)
163
-
164
285
  # Returns translation for a given locale, or builds one if none is present.
165
286
  # @param [Symbol] locale
166
287
  def translation_for(locale, _)
@@ -8,15 +8,9 @@ Defines read and write methods that access the value at a key with value
8
8
 
9
9
  =end
10
10
  module HashValued
11
- # @!macro backend_constructor
12
- # @option options [Symbol] column_prefix Prefix added to generate column
13
- # name from attribute name
14
- # @option options [Symbol] column_suffix Suffix added to generate column
15
- # name from attribute name
16
- def initialize(_model, _attribute, options = {})
17
- super
18
- @column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
19
- end
11
+ # @!method column_affix
12
+ # Returns interpolation string used to generate column names.
13
+ # @return [String] Affix to generate column names
20
14
 
21
15
  # @!group Backend Accessors
22
16
  #
@@ -36,10 +30,21 @@ Defines read and write methods that access the value at a key with value
36
30
  translations.each { |l, _| yield l }
37
31
  end
38
32
 
33
+ def self.included(backend_class)
34
+ backend_class.extend ClassMethods
35
+ backend_class.option_reader :column_affix
36
+ end
37
+
38
+ module ClassMethods
39
+ def configure(options)
40
+ options[:column_affix] = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
41
+ end
42
+ end
43
+
39
44
  private
40
45
 
41
46
  def column_name
42
- @column_name ||= (@column_affix % attribute)
47
+ @column_name ||= (column_affix % attribute)
43
48
  end
44
49
  end
45
50
 
@@ -46,15 +46,13 @@ other backends on model (otherwise one will overwrite the other).
46
46
  module KeyValue
47
47
  extend Backend::OrmDelegator
48
48
 
49
- # @return [Symbol] name of the association
50
- attr_reader :association_name
51
-
52
- # @!macro backend_constructor
53
- # @option options [Symbol] association_name Name of association
54
- def initialize(model, attribute, options = {})
55
- super
56
- @association_name = options[:association_name]
57
- end
49
+ # @!method association_name
50
+ # Returns the name of the polymorphic association.
51
+ # @return [Symbol] Name of the association
52
+
53
+ # @!method class_name
54
+ # Returns translation class used in polymorphic association.
55
+ # @return [Class] Translation class
58
56
 
59
57
  # @!group Backend Accessors
60
58
  # @!macro backend_reader
@@ -62,7 +60,7 @@ other backends on model (otherwise one will overwrite the other).
62
60
  translation_for(locale, options).value
63
61
  end
64
62
 
65
- # @!macro backend_reader
63
+ # @!macro backend_writer
66
64
  def write(locale, value, options = {})
67
65
  translation_for(locale, options).value = value
68
66
  end
@@ -79,8 +77,10 @@ other backends on model (otherwise one will overwrite the other).
79
77
  model.send(association_name)
80
78
  end
81
79
 
82
- def self.included(backend)
83
- backend.extend ClassMethods
80
+ def self.included(backend_class)
81
+ backend_class.extend ClassMethods
82
+ backend_class.option_reader :association_name
83
+ backend_class.option_reader :class_name
84
84
  end
85
85
 
86
86
  module ClassMethods
@@ -11,15 +11,9 @@ Implements the {Mobility::Backends::Container} backend for Sequel models.
11
11
  require 'mobility/backends/sequel/container/json_query_methods'
12
12
  require 'mobility/backends/sequel/container/jsonb_query_methods'
13
13
 
14
- # @return [Symbol] name of container column
15
- attr_reader :column_name
16
-
17
- # @!macro backend_constructor
18
- # @option options [Symbol] column_name Name of container column
19
- def initialize(model, attribute, options = {})
20
- super
21
- @column_name = options[:column_name]
22
- end
14
+ # @!method column_name
15
+ # @return [Symbol] (:translations) Name of translations column
16
+ option_reader :column_name
23
17
 
24
18
  # @!group Backend Accessors
25
19
  #
@@ -6,7 +6,7 @@ module Mobility
6
6
 
7
7
  Implements the {Mobility::Backends::Hstore} backend for Sequel models.
8
8
 
9
- @see Mobility::Backends::Sequel::HashValued
9
+ @see Mobility::Backends::HashValued
10
10
 
11
11
  =end
12
12
  module Sequel
@@ -15,7 +15,7 @@ Implements the {Mobility::Backends::Hstore} backend for Sequel models.
15
15
 
16
16
  # @!group Backend Accessors
17
17
  # @!macro backend_reader
18
- # @!method read(locale, **options)
18
+ # @!method read(locale, options = {})
19
19
 
20
20
  # @!group Backend Accessors
21
21
  # @!macro backend_writer
@@ -6,7 +6,7 @@ module Mobility
6
6
 
7
7
  Implements the {Mobility::Backends::Json} backend for Sequel models.
8
8
 
9
- @see Mobility::Backends::Sequel::HashValued
9
+ @see Mobility::Backends::HashValued
10
10
 
11
11
  =end
12
12
  module Sequel
@@ -15,21 +15,21 @@ Implements the {Mobility::Backends::Json} backend for Sequel models.
15
15
 
16
16
  # @!group Backend Accessors
17
17
  #
18
- # @note Translation may be any json type, but querying will only work on
19
- # string-typed values.
20
- # @param [Symbol] locale Locale to read
21
- # @param [Hash] options
22
- # @return [String,Integer,Boolean] Value of translation
23
- # @!method read(locale, **options)
18
+ # @!method read(locale, options = {})
19
+ # @note Translation may be any json type, but querying will only work on
20
+ # string-typed values.
21
+ # @param [Symbol] locale Locale to read
22
+ # @param [Hash] options
23
+ # @return [String,Integer,Boolean] Value of translation
24
24
 
25
- # @!group Backend Accessors
26
- # @note Translation may be any json type, but querying will only work on
27
- # string-typed values.
28
- # @param [Symbol] locale Locale to write
29
- # @param [String,Integer,Boolean] value Value to write
30
- # @param [Hash] options
31
- # @return [String,Integer,Boolean] Updated value
32
- # @!method write(locale, value, **options)
25
+ # @!method write(locale, value, options = {})
26
+ # @note Translation may be any json type, but querying will only work
27
+ # on string-typed values.
28
+ # @param [Symbol] locale Locale to write
29
+ # @param [String,Integer,Boolean] value Value to write
30
+ # @param [Hash] options
31
+ # @return [String,Integer,Boolean] Updated value
32
+ # @!endgroup
33
33
 
34
34
  setup_query_methods(QueryMethods)
35
35
  end
@@ -6,7 +6,7 @@ module Mobility
6
6
 
7
7
  Implements the {Mobility::Backends::Jsonb} backend for Sequel models.
8
8
 
9
- @see Mobility::Backends::Sequel::HashValued
9
+ @see Mobility::Backends::HashValued
10
10
 
11
11
  =end
12
12
  module Sequel
@@ -15,21 +15,21 @@ Implements the {Mobility::Backends::Jsonb} backend for Sequel models.
15
15
 
16
16
  # @!group Backend Accessors
17
17
  #
18
- # @note Translation may be string, integer or boolean-valued since
19
- # value is stored on a JSON hash.
20
- # @param [Symbol] locale Locale to read
21
- # @param [Hash] options
22
- # @return [String,Integer,Boolean] Value of translation
23
18
  # @!method read(locale, **options)
24
-
25
- # @!group Backend Accessors
26
- # @note Translation may be string, integer or boolean-valued since
27
- # value is stored on a JSON hash.
28
- # @param [Symbol] locale Locale to write
29
- # @param [String,Integer,Boolean] value Value to write
30
- # @param [Hash] options
31
- # @return [String,Integer,Boolean] Updated value
19
+ # @note Translation may be string, integer or boolean-valued since
20
+ # value is stored on a JSON hash.
21
+ # @param [Symbol] locale Locale to read
22
+ # @param [Hash] options
23
+ # @return [String,Integer,Boolean] Value of translation
24
+ #
32
25
  # @!method write(locale, value, **options)
26
+ # @note Translation may be string, integer or boolean-valued since
27
+ # value is stored on a JSON hash.
28
+ # @param [Symbol] locale Locale to write
29
+ # @param [String,Integer,Boolean] value Value to write
30
+ # @param [Hash] options
31
+ # @return [String,Integer,Boolean] Updated value
32
+ # @!endgroup
33
33
 
34
34
  setup_query_methods(QueryMethods)
35
35
  end