mobility 0.7.6 → 0.8.0

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