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,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