mobility 0.7.6 → 0.8.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 (52) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/CHANGELOG.md +14 -3
  5. data/Gemfile.lock +57 -0
  6. data/README.md +15 -4
  7. data/lib/mobility.rb +17 -1
  8. data/lib/mobility/active_record/uniqueness_validator.rb +1 -1
  9. data/lib/mobility/arel/nodes.rb +0 -3
  10. data/lib/mobility/arel/nodes/pg_ops.rb +0 -4
  11. data/lib/mobility/attributes.rb +42 -25
  12. data/lib/mobility/backends/active_record.rb +2 -2
  13. data/lib/mobility/backends/active_record/column.rb +2 -2
  14. data/lib/mobility/backends/active_record/key_value.rb +6 -9
  15. data/lib/mobility/backends/active_record/table.rb +6 -7
  16. data/lib/mobility/backends/column.rb +2 -2
  17. data/lib/mobility/backends/key_value.rb +5 -0
  18. data/lib/mobility/backends/sequel.rb +24 -11
  19. data/lib/mobility/backends/sequel/column.rb +4 -3
  20. data/lib/mobility/backends/sequel/container.rb +21 -12
  21. data/lib/mobility/backends/sequel/hstore.rb +11 -3
  22. data/lib/mobility/backends/sequel/json.rb +11 -3
  23. data/lib/mobility/backends/sequel/jsonb.rb +35 -3
  24. data/lib/mobility/backends/sequel/key_value.rb +97 -17
  25. data/lib/mobility/backends/sequel/serialized.rb +5 -4
  26. data/lib/mobility/backends/sequel/table.rb +95 -26
  27. data/lib/mobility/backends/table.rb +4 -0
  28. data/lib/mobility/configuration.rb +1 -1
  29. data/lib/mobility/plugins/active_record/query.rb +52 -26
  30. data/lib/mobility/plugins/locale_accessors.rb +3 -2
  31. data/lib/mobility/plugins/query.rb +3 -0
  32. data/lib/mobility/plugins/sequel/query.rb +140 -0
  33. data/lib/mobility/sequel.rb +0 -14
  34. data/lib/mobility/sequel/sql.rb +16 -0
  35. data/lib/mobility/version.rb +1 -1
  36. data/lib/rails/generators/mobility/templates/column_translations.rb +1 -1
  37. data/lib/rails/generators/mobility/templates/initializer.rb +3 -2
  38. data/lib/rails/generators/mobility/translations_generator.rb +1 -1
  39. metadata +27 -46
  40. metadata.gz.sig +1 -1
  41. data/lib/mobility/backends/active_record/query_methods.rb +0 -50
  42. data/lib/mobility/backends/sequel/column/query_methods.rb +0 -29
  43. data/lib/mobility/backends/sequel/container/json_query_methods.rb +0 -41
  44. data/lib/mobility/backends/sequel/container/jsonb_query_methods.rb +0 -41
  45. data/lib/mobility/backends/sequel/hstore/query_methods.rb +0 -34
  46. data/lib/mobility/backends/sequel/json/query_methods.rb +0 -34
  47. data/lib/mobility/backends/sequel/jsonb/query_methods.rb +0 -34
  48. data/lib/mobility/backends/sequel/key_value/query_methods.rb +0 -58
  49. data/lib/mobility/backends/sequel/pg_query_methods.rb +0 -114
  50. data/lib/mobility/backends/sequel/query_methods.rb +0 -36
  51. data/lib/mobility/backends/sequel/serialized/query_methods.rb +0 -22
  52. data/lib/mobility/backends/sequel/table/query_methods.rb +0 -58
@@ -36,8 +36,6 @@ Sequel serialization plugin.
36
36
  include Sequel
37
37
  include HashValued
38
38
 
39
- require 'mobility/backends/sequel/serialized/query_methods'
40
-
41
39
  # @!group Backend Configuration
42
40
  # @param (see Backends::Serialized.configure)
43
41
  # @option (see Backends::Serialized.configure)
@@ -48,6 +46,11 @@ Sequel serialization plugin.
48
46
  end
49
47
  # @!endgroup
50
48
 
49
+ def self.build_op(attr, _locale)
50
+ raise ArgumentError,
51
+ "You cannot query on mobility attributes translated with the Serialized backend (#{attr})."
52
+ end
53
+
51
54
  setup do |attributes, options|
52
55
  format = options[:format]
53
56
  columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym }
@@ -71,8 +74,6 @@ Sequel serialization plugin.
71
74
  include SerializationModificationDetectionFix
72
75
  end
73
76
 
74
- setup_query_methods(QueryMethods)
75
-
76
77
  # Returns deserialized column value
77
78
  # @return [Hash]
78
79
  def translations
@@ -3,6 +3,7 @@ require "mobility/util"
3
3
  require "mobility/backends/sequel"
4
4
  require "mobility/backends/key_value"
5
5
  require "mobility/sequel/model_translation"
6
+ require "mobility/sequel/sql"
6
7
 
7
8
  module Mobility
8
9
  module Backends
@@ -15,37 +16,107 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
15
16
  include Sequel
16
17
  include Table
17
18
 
18
- require 'mobility/backends/sequel/table/query_methods'
19
-
20
19
  def translation_class
21
20
  self.class.translation_class
22
21
  end
23
22
 
24
- # @return [Symbol] class for translations
25
- def self.translation_class
26
- @translation_class ||= model_class.const_get(subclass_name)
27
- end
23
+ class << self
24
+ # @return [Symbol] class for translations
25
+ def translation_class
26
+ @translation_class ||= model_class.const_get(subclass_name)
27
+ end
28
+
29
+ # @!group Backend Configuration
30
+ # @option options [Symbol] association_name (:translations) Name of association method
31
+ # @option options [Symbol] table_name Name of translation table
32
+ # @option options [Symbol] foreign_key Name of foreign key
33
+ # @option options [Symbol] subclass_name Name of subclass to append to model class to generate translation class
34
+ # @raise [CacheRequired] if cache option is false
35
+ def configure(options)
36
+ raise CacheRequired, "Cache required for Sequel::Table backend" if options[:cache] == false
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))
40
+ if association_name = options[:association_name]
41
+ options[:subclass_name] ||= Util.camelize(Util.singularize(association_name))
42
+ else
43
+ options[:association_name] = :translations
44
+ options[:subclass_name] ||= :Translation
45
+ end
46
+ %i[table_name foreign_key association_name subclass_name].each { |key| options[key] = options[key].to_sym }
47
+ end
48
+ # @!endgroup
49
+
50
+ # @param [Symbol] name Attribute name
51
+ # @param [Symbol] locale Locale
52
+ # @return [Sequel::SQL::QualifiedIdentifier]
53
+ def build_op(attr, locale)
54
+ ::Mobility::Sequel::SQL::QualifiedIdentifier.new(table_alias(locale), attr, locale, self, attribute_name: attr)
55
+ end
56
+
57
+ # @param [Sequel::Dataset] dataset Dataset to prepare
58
+ # @param [Object] predicate Predicate
59
+ # @param [Symbol] locale Locale
60
+ # @return [Sequel::Dataset] Prepared dataset
61
+ def prepare_dataset(dataset, predicate, locale)
62
+ join_translations(dataset, locale, visit(predicate, locale))
63
+ end
64
+
65
+ private
66
+
67
+ def join_translations(dataset, locale, join_type)
68
+ if joins = dataset.opts[:join]
69
+ return dataset if joins.any? { |clause| clause.table_expr.alias == table_alias(locale) }
70
+ end
71
+ dataset.join_table(join_type,
72
+ translation_class.table_name,
73
+ {
74
+ locale: locale.to_s,
75
+ foreign_key => ::Sequel[model_class.table_name][:id]
76
+ },
77
+ table_alias: table_alias(locale))
78
+ end
79
+
80
+ # @return [Symbol] Join type
81
+ def visit(predicate, locale)
82
+ case predicate
83
+ when Array
84
+ visit_collection(predicate, locale)
85
+ when ::Mobility::Sequel::SQL::QualifiedIdentifier
86
+ visit_sql_identifier(predicate, locale)
87
+ when ::Sequel::SQL::BooleanExpression
88
+ visit_boolean(predicate, locale)
89
+ when ::Sequel::SQL::Expression
90
+ visit(predicate.args, locale)
91
+ else
92
+ nil
93
+ end
94
+ end
95
+
96
+ def visit_collection(collection, locale)
97
+ collection.map { |obj|
98
+ visit(obj, locale).tap do |visited|
99
+ return visited if visited == :inner
100
+ end
101
+ }.compact.first
102
+ end
28
103
 
29
- # @!group Backend Configuration
30
- # @option options [Symbol] association_name (:translations) Name of association method
31
- # @option options [Symbol] table_name Name of translation table
32
- # @option options [Symbol] foreign_key Name of foreign key
33
- # @option options [Symbol] subclass_name Name of subclass to append to model class to generate translation class
34
- # @raise [CacheRequired] if cache option is false
35
- def self.configure(options)
36
- raise CacheRequired, "Cache required for Sequel::Table backend" if options[:cache] == false
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))
40
- if association_name = options[:association_name]
41
- options[:subclass_name] ||= Util.camelize(Util.singularize(association_name))
42
- else
43
- options[:association_name] = :translations
44
- options[:subclass_name] ||= :Translation
104
+ def visit_sql_identifier(identifier, locale)
105
+ (table_alias(locale) == identifier.table) && :inner
106
+ end
107
+
108
+ def visit_boolean(boolean, locale)
109
+ if boolean.op == :'='
110
+ boolean.args.any? { |op| visit(op, locale) } && :inner
111
+ elsif boolean.op == :IS
112
+ boolean.args.any?(&:nil?) && :left_outer
113
+ elsif boolean.op == :OR
114
+ boolean.args.any? { |op| visit(op, locale) } && :left_outer
115
+ else
116
+ visit(boolean.args, locale)
117
+ end
45
118
  end
46
- %i[table_name foreign_key association_name subclass_name].each { |key| options[key] = options[key].to_sym }
47
119
  end
48
- # @!endgroup
49
120
 
50
121
  setup do |attributes, options|
51
122
  association_name = options[:association_name]
@@ -86,8 +157,6 @@ Implements the {Mobility::Backends::Table} backend for Sequel models.
86
157
  include Mobility::Sequel::ColumnChanges.new(attributes)
87
158
  end
88
159
 
89
- setup_query_methods(QueryMethods)
90
-
91
160
  def translation_for(locale, _)
92
161
  translation = model.send(association_name).find { |t| t.locale == locale.to_s }
93
162
  translation ||= translation_class.new(locale: locale)
@@ -125,6 +125,10 @@ set.
125
125
  super
126
126
  end
127
127
  end
128
+
129
+ def table_alias(locale)
130
+ "#{table_name}_#{Mobility.normalize_locale(locale)}"
131
+ end
128
132
  end
129
133
 
130
134
  # Simple hash cache to memoize translations as a hash so they can be
@@ -104,7 +104,7 @@ default_fallbacks= will be removed in the next major version of Mobility.
104
104
  @accessor_method = :translates
105
105
  @query_method = :i18n
106
106
  @fallbacks_generator = lambda { |fallbacks| Mobility::Fallbacks.build(fallbacks) }
107
- @default_accessor_locales = lambda { I18n.available_locales }
107
+ @default_accessor_locales = lambda { Mobility.available_locales }
108
108
  @default_options = Options[{
109
109
  cache: true,
110
110
  presence: true,
@@ -17,9 +17,7 @@ enabled for any one attribute on the model.
17
17
  module Query
18
18
  class << self
19
19
  def apply(attributes)
20
- model_class = attributes.model_class
21
-
22
- model_class.class_eval do
20
+ attributes.model_class.class_eval do
23
21
  extend QueryMethod
24
22
  extend FindByMethods.new(*attributes.names)
25
23
  singleton_class.send :alias_method, Mobility.query_method, :__mobility_query_scope__
@@ -48,7 +46,7 @@ enabled for any one attribute on the model.
48
46
  end
49
47
 
50
48
  def method_missing(m, *)
51
- if @model_class.mobility_attributes.include?(m.to_s)
49
+ if @model_class.mobility_attribute?(m)
52
50
  @__backends |= [@model_class.mobility_backend_class(m)]
53
51
  @model_class.mobility_backend_class(m).build_node(m, @locale)
54
52
  elsif @model_class.column_names.include?(m.to_s)
@@ -91,6 +89,26 @@ enabled for any one attribute on the model.
91
89
  opts == :chain ? WhereChain.new(spawn) : super
92
90
  end
93
91
 
92
+ def order(opts, *rest)
93
+ case opts
94
+ when Symbol, String
95
+ @klass.mobility_attribute?(opts) ? order({ opts => :asc }, *rest) : super
96
+ when Hash
97
+ i18n_keys, keys = opts.keys.partition(&@klass.method(:mobility_attribute?))
98
+ return super if i18n_keys.empty?
99
+
100
+ base = keys.empty? ? self : super(opts.slice(keys))
101
+
102
+ i18n_keys.inject(base) do |query, key|
103
+ backend_class = @klass.mobility_backend_class(key)
104
+ dir, node = opts[key], backend_node(key)
105
+ backend_class.apply_scope(query, node).order(node.send(dir.downcase))
106
+ end
107
+ else
108
+ super
109
+ end
110
+ end
111
+
94
112
  # Return backend node for attribute name.
95
113
  # @param [Symbol,String] name Name of attribute
96
114
  # @param [Symbol] locale Locale
@@ -108,38 +126,42 @@ enabled for any one attribute on the model.
108
126
  end
109
127
 
110
128
  module QueryBuilder
129
+ IDENTITY = ->(x) { x }.freeze
130
+
111
131
  class << self
112
- def build(scope, where_opts, invert: false)
132
+ def build(scope, where_opts, invert: false, &block)
113
133
  return yield unless Hash === where_opts
114
134
 
115
135
  opts = where_opts.with_indifferent_access
116
136
  locale = opts.delete(:locale) || Mobility.locale
117
137
 
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] }
138
+ _build(scope, opts, locale, invert, &block)
123
139
  end
124
140
 
125
141
  private
126
142
 
127
- def build_maps!(scope, opts, locale, invert:)
128
- keys = opts.keys.map(&:to_s)
143
+ # Builds a translated relation for a given opts hash and optional
144
+ # invert boolean.
145
+ def _build(scope, opts, locale, invert)
146
+ keys, predicates = opts.keys.map(&:to_s), []
129
147
 
130
- scope.mobility_modules.map { |mod|
131
- next if (i18n_keys = mod.names & keys).empty?
148
+ query_map = scope.mobility_modules.inject(IDENTITY) do |qm, mod|
149
+ i18n_keys = mod.names & keys
150
+ next qm if i18n_keys.empty?
132
151
 
133
- predicates = i18n_keys.map do |key|
152
+ mod_predicates = i18n_keys.map do |key|
134
153
  build_predicate(scope.backend_node(key.to_sym, locale), opts.delete(key))
135
154
  end
155
+ invert_predicates!(mod_predicates) if invert
156
+ predicates += mod_predicates
136
157
 
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
158
+ ->(r) { mod.backend_class.apply_scope(qm[r], mod_predicates, locale, invert: invert) }
159
+ end
160
+
161
+ return yield if query_map == IDENTITY
162
+
163
+ relation = opts.empty? ? scope : yield(opts)
164
+ query_map[relation.where(predicates.inject(&:and))]
143
165
  end
144
166
 
145
167
  def build_predicate(node, values)
@@ -156,15 +178,19 @@ enabled for any one attribute on the model.
156
178
  Array.wrap(values).uniq.partition(&:nil?)
157
179
  end
158
180
 
181
+ def invert_predicates!(predicates)
182
+ predicates.map!(&method(:invert_predicate))
183
+ end
184
+
159
185
  # Adapted from AR::Relation::WhereClause#invert_predicate
160
- def invert_predicate(node)
161
- case node
186
+ def invert_predicate(predicate)
187
+ case predicate
162
188
  when ::Arel::Nodes::In
163
- ::Arel::Nodes::NotIn.new(node.left, node.right)
189
+ ::Arel::Nodes::NotIn.new(predicate.left, predicate.right)
164
190
  when ::Arel::Nodes::Equality
165
- ::Arel::Nodes::NotEqual.new(node.left, node.right)
191
+ ::Arel::Nodes::NotEqual.new(predicate.left, predicate.right)
166
192
  else
167
- ::Arel::Nodes::Not.new(node)
193
+ ::Arel::Nodes::Not.new(predicate)
168
194
  end
169
195
  end
170
196
  end
@@ -10,7 +10,8 @@ locales directly with a method call, using a suffix including the locale:
10
10
  article.title_pt_br
11
11
 
12
12
  If no locales are passed as an option to the initializer,
13
- +I18n.available_locales+ will be used by default.
13
+ +Mobility.available_locales+ (i.e. +I18n.available_locales+, or Rails-set
14
+ available locales for a Rails application) will be used by default.
14
15
 
15
16
  @example
16
17
  class Post
@@ -41,7 +42,7 @@ If no locales are passed as an option to the initializer,
41
42
 
42
43
  # @param [String] One or more attribute names
43
44
  # @param [Array<Symbol>] Locales
44
- def initialize(*attribute_names, locales: I18n.available_locales)
45
+ def initialize(*attribute_names, locales:)
45
46
  attribute_names.each do |name|
46
47
  locales.each do |locale|
47
48
  define_reader(name, locale)
@@ -20,6 +20,9 @@ module Mobility
20
20
  if Loaded::ActiveRecord && attributes.model_class < ::ActiveRecord::Base
21
21
  require "mobility/plugins/active_record/query"
22
22
  ActiveRecord::Query.apply(attributes)
23
+ elsif Loaded::Sequel && attributes.model_class < ::Sequel::Model
24
+ require "mobility/plugins/sequel/query"
25
+ Sequel::Query.apply(attributes)
23
26
  end
24
27
  end
25
28
  end
@@ -0,0 +1,140 @@
1
+ # frozen-string-literal: true
2
+ module Mobility
3
+ module Plugins
4
+ =begin
5
+
6
+ See ActiveRecord::Query plugin.
7
+
8
+ =end
9
+ module Sequel
10
+ module Query
11
+ class << self
12
+ def apply(attributes)
13
+ attributes.model_class.class_eval do
14
+ extend QueryMethod
15
+ singleton_class.send :alias_method, Mobility.query_method, :__mobility_query_dataset__
16
+ end
17
+ end
18
+ end
19
+
20
+ module QueryMethod
21
+ def __mobility_query_dataset__(locale: Mobility.locale, &block)
22
+ if block_given?
23
+ VirtualRow.build_query(self, locale, &block)
24
+ else
25
+ dataset.with_extend(QueryExtension)
26
+ end
27
+ end
28
+ end
29
+
30
+ # Internal class to create a "clean room" for manipulating translated
31
+ # attribute nodes in an instance-eval'ed block. Inspired by Sequel's
32
+ # (much more sophisticated) virtual rows.
33
+ class VirtualRow < BasicObject
34
+ attr_reader :__backends
35
+
36
+ def initialize(model_class, locale)
37
+ @model_class, @locale, @__backends = model_class, locale, []
38
+ end
39
+
40
+ def method_missing(m, *)
41
+ if @model_class.mobility_attribute?(m)
42
+ @__backends |= [@model_class.mobility_backend_class(m)]
43
+ @model_class.mobility_backend_class(m).build_op(m.to_s, @locale)
44
+ elsif @model_class.columns.include?(m.to_s)
45
+ ::Sequel::SQL::QualifiedIdentifier.new(@model_class.table_name, m)
46
+ else
47
+ super
48
+ end
49
+ end
50
+
51
+ class << self
52
+ def build_query(klass, locale, &block)
53
+ row = new(klass, locale)
54
+ query = block.arity.zero? ? row.instance_eval(&block) : block.call(row)
55
+
56
+ if ::Sequel::Dataset === query
57
+ predicates = query.opts[:where]
58
+ prepare_datasets(query, row.__backends, locale, predicates)
59
+ else
60
+ prepare_datasets(klass.dataset, row.__backends, locale, query).where(query)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def prepare_datasets(dataset, backends, locale, predicates)
67
+ backends.inject(dataset) { |ds, b| b.prepare_dataset(ds, predicates, locale) }
68
+ end
69
+ end
70
+ end
71
+ private_constant :QueryMethod, :VirtualRow
72
+
73
+ module QueryExtension
74
+ %w[exclude or where].each do |method_name|
75
+ module_eval <<-EOM, __FILE__, __LINE__ + 1
76
+ def #{method_name}(*conds, &block)
77
+ QueryBuilder.build(self, #{method_name.inspect}, conds) do |untranslated_conds|
78
+ untranslated_conds ? super(untranslated_conds, &block) : super
79
+ end
80
+ end
81
+ EOM
82
+ end
83
+
84
+ # Return backend node for attribute name.
85
+ # @param [Symbol,String] name Name of attribute
86
+ # @param [Symbol] locale Locale
87
+ # @return [Arel::Node] Arel node for this attribute in given locale
88
+ def backend_op(name, locale = Mobility.locale)
89
+ model.mobility_backend_class(name)[name, locale]
90
+ end
91
+ end
92
+
93
+ module QueryBuilder
94
+ IDENTITY = ->(x) { x }.freeze
95
+
96
+ class << self
97
+ def build(dataset, query_method, query_conds, &block)
98
+ return yield unless Hash === query_conds.first
99
+
100
+ cond = query_conds.first.dup
101
+ locale = cond.delete(:locale) || Mobility.locale
102
+
103
+ _build(dataset, cond, locale, query_method, &block)
104
+ end
105
+
106
+ private
107
+
108
+ def _build(dataset, cond, locale, query_method)
109
+ keys, predicates = cond.keys, []
110
+ model = dataset.model
111
+
112
+ query_map = model.mobility_modules.inject(IDENTITY) do |qm, mod|
113
+ i18n_keys = mod.names.map(&:to_sym) & keys
114
+ next qm if i18n_keys.empty?
115
+
116
+ mod_predicates = i18n_keys.map do |key|
117
+ build_predicate(dataset.backend_op(key, locale), cond.delete(key))
118
+ end
119
+ predicates += mod_predicates
120
+
121
+ ->(ds) { mod.backend_class.prepare_dataset(qm[ds], mod_predicates, locale) }
122
+ end
123
+
124
+ return yield if query_map == IDENTITY
125
+
126
+ predicates = ::Sequel.&(*predicates, cond) unless cond.empty?
127
+ query_map[dataset.public_send(query_method, ::Sequel.&(*predicates))]
128
+ end
129
+
130
+ def build_predicate(op, values)
131
+ vals = values.is_a?(Array) ? values.uniq: [values]
132
+ vals = vals.first if vals.size == 1
133
+ op =~ vals
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end