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,13 +25,12 @@ To use the validator, you must +extend Mobility+ before calling +validates+
25
25
  def validate_each(record, attribute, value)
26
26
  klass = record.class
27
27
 
28
- if (([*options[:scope]] + [attribute]).map(&:to_s) & klass.translated_attribute_names).present?
29
- warn %{
30
- WARNING: The Mobility uniqueness validator for translated attributes does not
31
- support case-insensitive validation. This option will be ignored for: #{attribute}
32
- } if options[:case_sensitive] == false
28
+ if (([*options[:scope]] + [attribute]).map(&:to_s) & klass.mobility_attributes).present?
33
29
  return unless value.present?
34
- relation = klass.send(Mobility.query_method).where(attribute => value)
30
+ relation = klass.__mobility_query_scope__ do |m|
31
+ node = m.__send__(attribute)
32
+ options[:case_sensitive] == false ? node.lower.eq(value.downcase) : node.eq(value)
33
+ end
35
34
  relation = relation.where.not(klass.primary_key => record.id) if record.persisted?
36
35
  relation = mobility_scope_relation(record, relation)
37
36
  relation = relation.merge(options[:conditions]) if options[:conditions]
@@ -50,8 +49,10 @@ support case-insensitive validation. This option will be ignored for: #{attribut
50
49
  private
51
50
 
52
51
  def mobility_scope_relation(record, relation)
53
- [*options[:scope]].inject(relation) do |scoped_relation, scope_item|
54
- scoped_relation.where(scope_item => record.send(scope_item))
52
+ [*options[:scope]].inject(relation.unscoped) do |scoped_relation, scope_item|
53
+ scoped_relation.__mobility_query_scope__ do |m|
54
+ m.__send__(scope_item).eq(record.send(scope_item))
55
+ end
55
56
  end
56
57
  end
57
58
  end
@@ -0,0 +1,20 @@
1
+ # frozen-string-literal: true
2
+ require "mobility/arel/nodes"
3
+ require "mobility/arel/visitor"
4
+
5
+ module Mobility
6
+ module Arel
7
+ class Attribute < ::Arel::Attributes::Attribute
8
+ attr_reader :backend_class
9
+ attr_reader :locale
10
+ attr_reader :attribute_name
11
+
12
+ def initialize(relation, column_name, locale, backend_class, attribute_name: nil)
13
+ @backend_class = backend_class
14
+ @locale = locale
15
+ @attribute_name = attribute_name || column_name
16
+ super(relation, column_name)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ # frozen-string-literal: true
2
+ module Mobility
3
+ module Arel
4
+ module Nodes
5
+ class Unary < ::Arel::Nodes::Unary; end
6
+ class Binary < ::Arel::Nodes::Binary; end
7
+ class Grouping < ::Arel::Nodes::Grouping; end
8
+ class Equality < ::Arel::Nodes::Equality; end
9
+
10
+ ::Arel::Visitors::ToSql.class_eval do
11
+ alias :visit_Mobility_Arel_Nodes_Equality :visit_Arel_Nodes_Equality
12
+ alias :visit_Mobility_Arel_Nodes_Grouping :visit_Arel_Nodes_Grouping
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,136 @@
1
+ # frozen-string-literal: true
2
+ require "mobility/arel"
3
+
4
+ module Mobility
5
+ module Arel
6
+ module Nodes
7
+ %w[
8
+ JsonDashArrow
9
+ JsonDashDoubleArrow
10
+ JsonbDashArrow
11
+ JsonbDashDoubleArrow
12
+ JsonbQuestion
13
+ HstoreDashArrow
14
+ HstoreQuestion
15
+ ].each do |name|
16
+ const_set name, (Class.new(Binary) do
17
+ include ::Arel::Expressions
18
+ include ::Arel::Predications
19
+ include ::Arel::OrderPredications
20
+ include ::Arel::AliasPredication
21
+
22
+ def eq other
23
+ Equality.new self, quoted_node(other)
24
+ end
25
+
26
+ def lower
27
+ super self
28
+ end
29
+ end)
30
+ end
31
+
32
+ # Needed for AR 4.2, can be removed when support is deprecated
33
+ if ::ActiveRecord::VERSION::STRING < '5.0'
34
+ [JsonbDashDoubleArrow, HstoreDashArrow].each do |klass|
35
+ klass.class_eval do
36
+ def quoted_node other
37
+ other && super
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ class Jsonb < JsonbDashDoubleArrow
44
+ def to_dash_arrow
45
+ JsonbDashArrow.new left, right
46
+ end
47
+
48
+ def to_question
49
+ JsonbQuestion.new left, right
50
+ end
51
+
52
+ def eq other
53
+ case other
54
+ when NilClass
55
+ to_question.not
56
+ when Integer, Array, Hash
57
+ to_dash_arrow.eq other.to_json
58
+ when Jsonb
59
+ to_dash_arrow.eq other.to_dash_arrow
60
+ when JsonbDashArrow
61
+ to_dash_arrow.eq other
62
+ else
63
+ super
64
+ end
65
+ end
66
+ end
67
+
68
+ class Hstore < HstoreDashArrow
69
+ def to_question
70
+ HstoreQuestion.new left, right
71
+ end
72
+
73
+ def eq other
74
+ other.nil? ? to_question.not : super
75
+ end
76
+ end
77
+
78
+ class Json < JsonDashDoubleArrow; end
79
+
80
+ class JsonContainer < Json
81
+ def initialize column, locale, attr
82
+ super(Arel::Nodes::JsonDashArrow.new(column, locale), attr)
83
+ end
84
+ end
85
+
86
+ class JsonbContainer < Jsonb
87
+ def initialize column, locale, attr
88
+ @column, @locale = column, locale
89
+ super(JsonbDashArrow.new(column, locale), attr)
90
+ end
91
+
92
+ def eq other
93
+ other.nil? ? super.or(JsonbQuestion.new(@column, @locale).not) : super
94
+ end
95
+ end
96
+ end
97
+
98
+ module Visitors
99
+ def visit_Mobility_Arel_Nodes_JsonDashArrow o, a
100
+ json_infix o, a, '->'
101
+ end
102
+
103
+ def visit_Mobility_Arel_Nodes_JsonDashDoubleArrow o, a
104
+ json_infix o, a, '->>'
105
+ end
106
+
107
+ def visit_Mobility_Arel_Nodes_JsonbDashArrow o, a
108
+ json_infix o, a, '->'
109
+ end
110
+
111
+ def visit_Mobility_Arel_Nodes_JsonbDashDoubleArrow o, a
112
+ json_infix o, a, '->>'
113
+ end
114
+
115
+ def visit_Mobility_Arel_Nodes_JsonbQuestion o, a
116
+ json_infix o, a, '?'
117
+ end
118
+
119
+ def visit_Mobility_Arel_Nodes_HstoreDashArrow o, a
120
+ json_infix o, a, '->'
121
+ end
122
+
123
+ def visit_Mobility_Arel_Nodes_HstoreQuestion o, a
124
+ json_infix o, a, '?'
125
+ end
126
+
127
+ private
128
+
129
+ def json_infix o, a, opr
130
+ visit(Nodes::Grouping.new(::Arel::Nodes::InfixOperation.new(opr, o.left, o.right)), a)
131
+ end
132
+ end
133
+
134
+ ::Arel::Visitors::PostgreSQL.include Visitors
135
+ end
136
+ end
@@ -0,0 +1,61 @@
1
+ # frozen-string-literal: true
2
+ module Mobility
3
+ module Arel
4
+ class Visitor < ::Arel::Visitors::Visitor
5
+ INNER_JOIN = ::Arel::Nodes::InnerJoin
6
+ OUTER_JOIN = ::Arel::Nodes::OuterJoin
7
+
8
+ attr_reader :backend_class, :locale
9
+
10
+ def initialize(backend_class, locale)
11
+ super()
12
+ @backend_class, @locale = backend_class, locale
13
+ end
14
+
15
+ private
16
+
17
+ def visit(object)
18
+ super
19
+ rescue TypeError
20
+ visit_default(object)
21
+ end
22
+
23
+ def visit_collection(_objects)
24
+ raise NotImplementedError
25
+ end
26
+ alias :visit_Array :visit_collection
27
+
28
+ def visit_Arel_Nodes_Unary(object)
29
+ visit(object.expr)
30
+ end
31
+
32
+ def visit_Arel_Nodes_Binary(object)
33
+ visit_collection([object.left, object.right])
34
+ end
35
+
36
+ def visit_Arel_Nodes_Function(object)
37
+ visit_collection(object.expressions)
38
+ end
39
+
40
+ def visit_Arel_Nodes_Case(object)
41
+ visit_collection([object.case, object.conditions, object.default])
42
+ end
43
+
44
+ def visit_Arel_Nodes_And(object)
45
+ visit_Array(object.children)
46
+ end
47
+
48
+ def visit_Arel_Nodes_Node(object)
49
+ visit_default(object)
50
+ end
51
+
52
+ def visit_Arel_Attributes_Attribute(object)
53
+ visit_default(object)
54
+ end
55
+
56
+ def visit_default(_object)
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
@@ -133,20 +133,18 @@ with other backends.
133
133
  raise ArgumentError, "method must be one of: reader, writer, accessor" unless %i[reader writer accessor].include?(method)
134
134
  @method = method
135
135
  @options = Mobility.default_options.to_h.merge(backend_options)
136
- @names = attribute_names.map(&:to_s)
137
- raise Mobility::BackendRequired, "Backend option required if Mobility.config.default_backend is not set." if backend.nil?
136
+ @names = attribute_names.map(&:to_s).freeze
137
+ raise BackendRequired, "Backend option required if Mobility.config.default_backend is not set." if backend.nil?
138
138
  @backend_name = backend
139
139
  end
140
140
 
141
- # Setup backend class, include modules into model class, add this
142
- # attributes module to shared {Mobility::Accumulator} and setup model with
143
- # backend setup block (see {Mobility::Backend::Setup#setup_model}).
141
+ # Setup backend class, include modules into model class, include/extend
142
+ # shared modules and setup model with backend setup block (see
143
+ # {Mobility::Backend::Setup#setup_model}).
144
144
  # @param klass [Class] Class of model
145
145
  def included(klass)
146
146
  @model_class = @options[:model_class] = klass
147
- @backend_class = Class.new(get_backend_class(backend_name).for(model_class))
148
-
149
- @backend_class.configure(options) if @backend_class.respond_to?(:configure)
147
+ @backend_class = get_backend_class(backend_name).for(model_class).with_options(options)
150
148
 
151
149
  Mobility.plugins.each do |name|
152
150
  plugin = get_plugin_class(name)
@@ -159,8 +157,10 @@ with other backends.
159
157
  define_writer(name) if %i[accessor writer].include?(method)
160
158
  end
161
159
 
162
- model_class.mobility << self
163
- backend_class.setup_model(model_class, names, options)
160
+ klass.include InstanceMethods
161
+ klass.extend ClassMethods
162
+
163
+ backend_class.setup_model(model_class, names)
164
164
  end
165
165
 
166
166
  # Yield each attribute name to block
@@ -169,6 +169,8 @@ with other backends.
169
169
  names.each(&block)
170
170
  end
171
171
 
172
+ # Show useful information about this module.
173
+ # @return [String]
172
174
  def inspect
173
175
  "#<Attributes (#{backend_name}) @names=#{names.join(", ")}>"
174
176
  end
@@ -176,37 +178,35 @@ with other backends.
176
178
  private
177
179
 
178
180
  def define_backend(attribute)
179
- backend_class_, options_ = backend_class, options
180
- define_method Backend.method_name(attribute) do
181
- @mobility_backends ||= {}
182
- @mobility_backends[attribute] ||= backend_class_.new(self, attribute, options_)
181
+ module_eval <<-EOM, __FILE__, __LINE__ + 1
182
+ def #{Backend.method_name(attribute)}
183
+ mobility_backends[:#{attribute}]
183
184
  end
185
+ EOM
184
186
  end
185
187
 
186
188
  def define_reader(attribute)
187
- backend = Backend.method_name(attribute)
188
189
  class_eval <<-EOM, __FILE__, __LINE__ + 1
189
190
  def #{attribute}(**options)
190
191
  return super() if options.delete(:super)
191
192
  #{set_locale_from_options_inline}
192
- #{backend}.read(locale, options)
193
+ mobility_backends[:#{attribute}].read(locale, options)
193
194
  end
194
195
 
195
196
  def #{attribute}?(**options)
196
197
  return super() if options.delete(:super)
197
198
  #{set_locale_from_options_inline}
198
- #{backend}.present?(locale, options)
199
+ mobility_backends[:#{attribute}].present?(locale, options)
199
200
  end
200
201
  EOM
201
202
  end
202
203
 
203
204
  def define_writer(attribute)
204
- backend = Backend.method_name(attribute)
205
205
  class_eval <<-EOM, __FILE__, __LINE__ + 1
206
206
  def #{attribute}=(value, **options)
207
207
  return super(value) if options.delete(:super)
208
208
  #{set_locale_from_options_inline}
209
- #{backend}.write(locale, value, options)
209
+ mobility_backends[:#{attribute}].write(locale, value, options)
210
210
  end
211
211
  EOM
212
212
  end
@@ -240,5 +240,68 @@ EOL
240
240
  klass_name = key.to_s.gsub(/(^|_)(.)/){|x| x[-1..-1].upcase}
241
241
  parent_class.const_get(klass_name)
242
242
  end
243
+
244
+ module InstanceMethods
245
+ # Return a new backend for an attribute name.
246
+ # @return [Hash] Hash of attribute names and backend instances
247
+ def mobility_backends
248
+ @mobility_backends ||= Hash.new do |hash, name|
249
+ next hash[name.to_sym] if String === name
250
+ hash[name] = self.class.mobility_backend_class(name).new(self, name.to_s)
251
+ end
252
+ end
253
+
254
+ def initialize_dup(other)
255
+ @mobility_backends = nil
256
+ super
257
+ end
258
+ end
259
+
260
+ module ClassMethods
261
+ # Return all {Mobility::Attribute} module instances from among ancestors
262
+ # of this model.
263
+ # @return [Array<Mobility::Attributes>] Attribute modules
264
+ def mobility_modules
265
+ ancestors.select { |mod| Attributes === mod }
266
+ end
267
+
268
+ # Return translated attribute names on this model.
269
+ # @return [Array<String>] Attribute names
270
+ def mobility_attributes
271
+ mobility_modules.map(&:names).flatten
272
+ end
273
+
274
+ # @!method translated_attribute_names
275
+ # @return (see #mobility_attributes)
276
+ alias translated_attribute_names mobility_attributes
277
+
278
+ # Return backend class for a given attribute name.
279
+ # @param [Symbol,String] Name of attribute
280
+ # @return [Class] Backend class
281
+ def mobility_backend_class(name)
282
+ @backends ||= BackendsCache.new(self)
283
+ @backends[name.to_sym]
284
+ end
285
+
286
+ class BackendsCache < Hash
287
+ def initialize(klass)
288
+ # Preload backend mapping
289
+ klass.mobility_modules.each do |mod|
290
+ mod.names.each { |name| self[name.to_sym] = mod.backend_class }
291
+ end
292
+
293
+ super() do |hash, name|
294
+ if mod = klass.mobility_modules.find { |m| m.names.include? name.to_s }
295
+ hash[name] = mod.backend_class
296
+ else
297
+ raise KeyError, "No backend for: #{name}."
298
+ end
299
+ end
300
+ end
301
+ end
302
+ private_constant :BackendsCache
303
+ end
243
304
  end
305
+
306
+ class BackendRequired < ArgumentError; end
244
307
  end
@@ -66,7 +66,7 @@ On top of this, a backend will normally:
66
66
  # @!macro [new] backend_constructor
67
67
  # @param model Model on which backend is defined
68
68
  # @param [String] attribute Backend attribute
69
- def initialize(model, attribute, **_)
69
+ def initialize(model, attribute)
70
70
  @model = model
71
71
  @attribute = attribute
72
72
  end
@@ -106,10 +106,22 @@ On top of this, a backend will normally:
106
106
  Util.present?(read(locale, options))
107
107
  end
108
108
 
109
- # Extend included class with +setup+ method
109
+ # @!method model_class
110
+ # Returns name of model in which backend is used.
111
+ # @return [Class] Model class
112
+
113
+ # @return [Hash] options
114
+ def options
115
+ self.class.options
116
+ end
117
+
118
+ # Extend included class with +setup+ method and other class methods
110
119
  def self.included(base)
111
- base.extend(Setup)
112
- base.extend(ClassMethods)
120
+ base.extend ClassMethods
121
+ def base.options
122
+ @options
123
+ end
124
+ base.option_reader :model_class
113
125
  end
114
126
 
115
127
  # @param [String] attribute
@@ -120,7 +132,7 @@ On top of this, a backend will normally:
120
132
  end
121
133
 
122
134
  # Defines setup hooks for backend to customize model class.
123
- module Setup
135
+ module ClassMethods
124
136
  # Assign block to be called on model class.
125
137
  # @yield [attribute_names, options]
126
138
  # @note When called multiple times, setup blocks will be appended
@@ -139,19 +151,46 @@ On top of this, a backend will normally:
139
151
 
140
152
  def inherited(subclass)
141
153
  subclass.instance_variable_set(:@setup_block, @setup_block)
154
+ subclass.instance_variable_set(:@options, @options)
142
155
  end
143
156
 
144
157
  # Call setup block on a class with attributes and options.
145
158
  # @param model_class Class to be setup-ed
146
159
  # @param [Array<String>] attribute_names
147
160
  # @param [Hash] options
148
- def setup_model(model_class, attribute_names, **options)
161
+ def setup_model(model_class, attribute_names)
149
162
  return unless setup_block = @setup_block
150
163
  model_class.class_exec(attribute_names, options, &setup_block)
151
164
  end
152
- end
153
165
 
154
- module ClassMethods
166
+ # Build a subclass of this backend class for a given set of options
167
+ # @note This method also freezes the options hash to prevent it from
168
+ # being changed.
169
+ # @param [Hash] options
170
+ # @return [Class] backend subclass
171
+ def with_options(options = {}, &block)
172
+ configure(options) if respond_to?(:configure)
173
+ options.freeze
174
+ Class.new(self) do
175
+ @options = options
176
+ class_eval(&block) if block_given?
177
+ end
178
+ end
179
+
180
+ # Create instance and class methods to access value on options hash
181
+ # @param [Symbol] name Name of option reader
182
+ def option_reader(name)
183
+ module_eval <<-EOM, __FILE__, __LINE__ + 1
184
+ def self.#{name}
185
+ options[:#{name}]
186
+ end
187
+
188
+ def #{name}
189
+ self.class.options[:#{name}]
190
+ end
191
+ EOM
192
+ end
193
+
155
194
  # {Attributes} uses this method to get a backend class specific to the
156
195
  # model using the backend. Backend classes can override this method to
157
196
  # return a class specific to the model class using the backend (e.g.
@@ -172,6 +211,12 @@ On top of this, a backend will normally:
172
211
  def apply_plugin(_)
173
212
  false
174
213
  end
214
+
215
+ # Show useful information about this backend class, if it has no name.
216
+ # @return [String]
217
+ def inspect
218
+ name ? super : "#<#{superclass.name}>"
219
+ end
175
220
  end
176
221
 
177
222
  Translation = Struct.new(:backend, :locale) do