mobility 0.5.1 → 0.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data.tar.gz.sig +0 -0
  4. data/CHANGELOG.md +41 -1
  5. data/Gemfile.lock +3 -58
  6. data/README.md +22 -21
  7. data/lib/mobility.rb +3 -2
  8. data/lib/mobility/accumulator.rb +1 -2
  9. data/lib/mobility/active_model/backend_resetter.rb +1 -1
  10. data/lib/mobility/active_record.rb +12 -9
  11. data/lib/mobility/active_record/backend_resetter.rb +6 -7
  12. data/lib/mobility/active_record/uniqueness_validator.rb +12 -2
  13. data/lib/mobility/adapter.rb +1 -0
  14. data/lib/mobility/attributes.rb +3 -13
  15. data/lib/mobility/backends/active_record/column.rb +1 -0
  16. data/lib/mobility/backends/active_record/column/query_methods.rb +25 -20
  17. data/lib/mobility/backends/active_record/container/json_query_methods.rb +22 -16
  18. data/lib/mobility/backends/active_record/container/jsonb_query_methods.rb +19 -19
  19. data/lib/mobility/backends/active_record/hstore.rb +14 -12
  20. data/lib/mobility/backends/active_record/hstore/query_methods.rb +14 -5
  21. data/lib/mobility/backends/active_record/json.rb +21 -19
  22. data/lib/mobility/backends/active_record/json/query_methods.rb +16 -11
  23. data/lib/mobility/backends/active_record/jsonb.rb +21 -19
  24. data/lib/mobility/backends/active_record/jsonb/query_methods.rb +14 -5
  25. data/lib/mobility/backends/active_record/key_value.rb +9 -9
  26. data/lib/mobility/backends/active_record/key_value/query_methods.rb +53 -46
  27. data/lib/mobility/backends/active_record/pg_hash.rb +29 -25
  28. data/lib/mobility/backends/active_record/pg_query_methods.rb +76 -40
  29. data/lib/mobility/backends/active_record/query_methods.rb +17 -10
  30. data/lib/mobility/backends/active_record/serialized.rb +4 -2
  31. data/lib/mobility/backends/active_record/serialized/query_methods.rb +18 -15
  32. data/lib/mobility/backends/active_record/table.rb +21 -12
  33. data/lib/mobility/backends/active_record/table/query_methods.rb +82 -83
  34. data/lib/mobility/backends/hash_valued.rb +19 -0
  35. data/lib/mobility/backends/hstore.rb +3 -1
  36. data/lib/mobility/backends/json.rb +3 -1
  37. data/lib/mobility/backends/jsonb.rb +3 -1
  38. data/lib/mobility/backends/key_value.rb +32 -15
  39. data/lib/mobility/backends/sequel/column/query_methods.rb +16 -12
  40. data/lib/mobility/backends/sequel/container/json_query_methods.rb +25 -18
  41. data/lib/mobility/backends/sequel/container/jsonb_query_methods.rb +25 -18
  42. data/lib/mobility/backends/sequel/hstore.rb +14 -12
  43. data/lib/mobility/backends/sequel/hstore/query_methods.rb +18 -11
  44. data/lib/mobility/backends/sequel/json.rb +21 -19
  45. data/lib/mobility/backends/sequel/json/query_methods.rb +18 -11
  46. data/lib/mobility/backends/sequel/jsonb.rb +21 -19
  47. data/lib/mobility/backends/sequel/jsonb/query_methods.rb +18 -11
  48. data/lib/mobility/backends/sequel/key_value.rb +10 -11
  49. data/lib/mobility/backends/sequel/key_value/query_methods.rb +39 -34
  50. data/lib/mobility/backends/sequel/pg_hash.rb +37 -25
  51. data/lib/mobility/backends/sequel/pg_query_methods.rb +45 -20
  52. data/lib/mobility/backends/sequel/query_methods.rb +5 -0
  53. data/lib/mobility/backends/sequel/serialized.rb +18 -13
  54. data/lib/mobility/backends/sequel/serialized/query_methods.rb +10 -7
  55. data/lib/mobility/backends/sequel/table.rb +1 -1
  56. data/lib/mobility/backends/sequel/table/query_methods.rb +40 -35
  57. data/lib/mobility/plugins/cache/translation_cacher.rb +15 -15
  58. data/lib/mobility/plugins/default.rb +0 -7
  59. data/lib/mobility/plugins/fallbacks.rb +4 -0
  60. data/lib/mobility/sequel.rb +11 -5
  61. data/lib/mobility/sequel/backend_resetter.rb +6 -7
  62. data/lib/mobility/sequel/column_changes.rb +4 -4
  63. data/lib/mobility/version.rb +1 -1
  64. data/lib/rails/generators/mobility/backend_generators/base.rb +4 -0
  65. data/lib/rails/generators/mobility/backend_generators/table_backend.rb +0 -12
  66. data/lib/rails/generators/mobility/templates/column_translations.rb +2 -2
  67. data/lib/rails/generators/mobility/templates/create_string_translations.rb +5 -5
  68. data/lib/rails/generators/mobility/templates/create_text_translations.rb +5 -5
  69. data/lib/rails/generators/mobility/templates/initializer.rb +8 -0
  70. data/lib/rails/generators/mobility/templates/table_migration.rb +2 -3
  71. data/lib/rails/generators/mobility/templates/table_translations.rb +3 -4
  72. data/lib/rails/generators/mobility/translations_generator.rb +6 -5
  73. metadata +2 -3
  74. metadata.gz.sig +0 -0
  75. data/lib/mobility/backend/stringify_locale.rb +0 -18
@@ -12,6 +12,7 @@ Implements the {Mobility::Backends::KeyValue} backend for ActiveRecord models.
12
12
 
13
13
  @example
14
14
  class Post < ActiveRecord::Base
15
+ extend Mobility
15
16
  translates :title, backend: :key_value, association_name: :translations, type: :string
16
17
  end
17
18
 
@@ -31,17 +32,16 @@ Implements the {Mobility::Backends::KeyValue} backend for ActiveRecord models.
31
32
  require 'mobility/backends/active_record/key_value/query_methods'
32
33
 
33
34
  # @!group Backend Configuration
34
- # @option options [Symbol] type (:text) Column type to use
35
- # @option options [Symbol] association_name (:text_translations) Name of association method
36
- # @option options [String,Class] class_name ({Mobility::ActiveRecord::TextTranslation}) Translation class
37
- # @raise [ArgumentError] if type is not either :text or :string
35
+ # @option (see Mobility::Backends::KeyValue::ClassMethods#configure)
36
+ # @raise (see Mobility::Backends::KeyValue::ClassMethods#configure)
38
37
  def self.configure(options)
39
38
  super
40
- type = options[:type]
41
- options[:class_name] ||= Mobility::ActiveRecord.const_get("#{type.capitalize}Translation")
42
- options[:class_name] = options[:class_name].constantize if options[:class_name].is_a?(String)
43
- options[:association_name] ||= :"#{options[:type]}_translations"
44
- %i[type association_name].each { |key| options[key] = options[key].to_sym }
39
+ if type = options[:type]
40
+ options[:association_name] ||= :"#{options[:type]}_translations"
41
+ options[:class_name] ||= Mobility::ActiveRecord.const_get("#{type.capitalize}Translation")
42
+ end
43
+ rescue NameError
44
+ raise ArgumentError, "You must define a Mobility::ActiveRecord::#{type.capitalize}Translation class."
45
45
  end
46
46
  # @!endgroup
47
47
 
@@ -3,67 +3,74 @@ require "mobility/backends/active_record/query_methods"
3
3
 
4
4
  module Mobility
5
5
  module Backends
6
- class ActiveRecord::KeyValue::QueryMethods < ActiveRecord::QueryMethods
7
- def initialize(attributes, association_name: nil, class_name: nil, **)
8
- super
9
- @association_name = association_name
6
+ module ActiveRecord
7
+ class KeyValue::QueryMethods < QueryMethods
8
+ def initialize(attributes, association_name: nil, class_name: nil, **)
9
+ super
10
+ @association_name = association_name
10
11
 
11
- define_join_method(association_name, class_name)
12
- define_query_methods(association_name)
13
- end
12
+ define_join_method(association_name, class_name)
13
+ define_query_methods(association_name)
14
+ end
14
15
 
15
- def extended(relation)
16
- super
17
- association_name = @association_name
18
- q = self
16
+ def extended(relation)
17
+ super
18
+ association_name = @association_name
19
+ q = self
19
20
 
20
- mod = Module.new do
21
- define_method :not do |opts, *rest|
22
- if i18n_keys = q.extract_attributes(opts)
23
- opts = opts.with_indifferent_access
24
- i18n_keys.each { |attr| opts["#{attr}_#{association_name}"] = { value: opts.delete(attr) }}
25
- super(opts, *rest).send(:"join_#{association_name}", *i18n_keys)
26
- else
27
- super(opts, *rest)
21
+ mod = Module.new do
22
+ define_method :not do |opts, *rest|
23
+ if i18n_keys = q.extract_attributes(opts)
24
+ opts = opts.with_indifferent_access
25
+ i18n_keys.each do |attr|
26
+ opts["#{attr}_#{association_name}"] = { value: q.collapse(opts.delete(attr)) }
27
+ end
28
+ super(opts, *rest).send(:"join_#{association_name}", *i18n_keys)
29
+ else
30
+ super(opts, *rest)
31
+ end
28
32
  end
29
33
  end
34
+ relation.mobility_where_chain.include(mod)
30
35
  end
31
- relation.mobility_where_chain.include(mod)
32
- end
33
36
 
34
- private
37
+ private
35
38
 
36
- def define_join_method(association_name, translation_class)
37
- define_method :"join_#{association_name}" do |*attributes, **options|
38
- attributes.inject(self) do |relation, attribute|
39
- t = translation_class.arel_table.alias(:"#{attribute}_#{association_name}")
40
- m = arel_table
41
- join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin
42
- relation.joins(m.join(t, join_type).
43
- on(t[:key].eq(attribute).
44
- and(t[:locale].eq(Mobility.locale).
45
- and(t[:translatable_type].eq(base_class.name).
46
- and(t[:translatable_id].eq(m[:id]))))).join_sources)
39
+ def define_join_method(association_name, translation_class)
40
+ define_method :"join_#{association_name}" do |*attributes, **options|
41
+ attributes.inject(self) do |relation, attribute|
42
+ t = translation_class.arel_table.alias("#{attribute}_#{association_name}")
43
+ m = arel_table
44
+ join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin
45
+ relation.joins(m.join(t, join_type).
46
+ on(t[:key].eq(attribute).
47
+ and(t[:locale].eq(Mobility.locale).
48
+ and(t[:translatable_type].eq(base_class.name).
49
+ and(t[:translatable_id].eq(m[:id]))))).join_sources)
50
+ end
47
51
  end
48
52
  end
49
- end
50
53
 
51
- def define_query_methods(association_name)
52
- q = self
54
+ def define_query_methods(association_name)
55
+ q = self
53
56
 
54
- define_method :where! do |opts, *rest|
55
- if i18n_keys = q.extract_attributes(opts)
56
- opts = opts.with_indifferent_access
57
- i18n_nulls = i18n_keys.reject { |key| opts[key] && Array(opts[key]).all? }
58
- i18n_keys.each { |attr| opts["#{attr}_#{association_name}"] = { value: opts.delete(attr) }}
59
- super(opts, *rest).
60
- send("join_#{association_name}", *(i18n_keys - i18n_nulls)).
61
- send("join_#{association_name}", *i18n_nulls, outer_join: true)
62
- else
63
- super(opts, *rest)
57
+ define_method :where! do |opts, *rest|
58
+ if i18n_keys = q.extract_attributes(opts)
59
+ opts = opts.with_indifferent_access
60
+ i18n_nulls = i18n_keys.reject { |key| opts[key] && [*opts[key]].all? }
61
+ i18n_keys.each do |attr|
62
+ opts["#{attr}_#{association_name}"] = { value: q.collapse(opts.delete(attr)) }
63
+ end
64
+ super(opts, *rest).
65
+ send("join_#{association_name}", *(i18n_keys - i18n_nulls)).
66
+ send("join_#{association_name}", *i18n_nulls, outer_join: true)
67
+ else
68
+ super(opts, *rest)
69
+ end
64
70
  end
65
71
  end
66
72
  end
73
+ KeyValue.private_constant :QueryMethods
67
74
  end
68
75
  end
69
76
  end
@@ -10,39 +10,43 @@ Internal class used by ActiveRecord backends backed by a Postgres data type
10
10
  (hstore, jsonb).
11
11
 
12
12
  =end
13
- class ActiveRecord::PgHash
14
- include ActiveRecord
15
- include HashValued
16
-
17
- # @!macro backend_iterator
18
- def each_locale
19
- super { |l| yield l.to_sym }
20
- end
13
+ module ActiveRecord
14
+ class PgHash
15
+ include ActiveRecord
16
+ include HashValued
17
+
18
+ # @!macro backend_iterator
19
+ def each_locale
20
+ super { |l| yield l.to_sym }
21
+ end
21
22
 
22
- def translations
23
- model.read_attribute(attribute)
24
- end
23
+ def translations
24
+ model.read_attribute(column_name)
25
+ end
25
26
 
26
- setup do |attributes|
27
- attributes.each { |attribute| store attribute, coder: Coder }
28
- end
27
+ setup do |attributes, options = {}|
28
+ affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
29
+ attributes.each { |attribute| store (affix % attribute), coder: Coder }
30
+ end
29
31
 
30
- class Coder
31
- def self.dump(obj)
32
- if obj.is_a? Hash
33
- obj.inject({}) do |translations, (locale, value)|
34
- translations[locale] = value if value.present?
35
- translations
32
+ class Coder
33
+ def self.dump(obj)
34
+ if obj.is_a? Hash
35
+ obj.inject({}) do |translations, (locale, value)|
36
+ translations[locale] = value if value.present?
37
+ translations
38
+ end
39
+ else
40
+ raise ArgumentError, "Attribute is supposed to be a Hash, but was a #{obj.class}. -- #{obj.inspect}"
36
41
  end
37
- else
38
- raise ArgumentError, "Attribute is supposed to be a Hash, but was a #{obj.class}. -- #{obj.inspect}"
39
42
  end
40
- end
41
43
 
42
- def self.load(obj)
43
- obj
44
+ def self.load(obj)
45
+ obj
46
+ end
44
47
  end
45
48
  end
49
+ private_constant :PgHash
46
50
  end
47
51
  end
48
52
  end
@@ -1,11 +1,23 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mobility
2
4
  module Backends
3
5
  module ActiveRecord
4
6
  =begin
5
7
 
6
- Defines query methods for Postgres backends. Including class must define a
7
- single method, +matches+, which accepts a column, value and locale to
8
- match, and returns an Arel node.
8
+ Internal module builder defining query methods for Postgres backends. Including
9
+ class must define the following methods:
10
+
11
+ - a method +matches+ which takes an attribute, and a locale to match, and
12
+ returns an Arel node which is used to check that the attribute has the
13
+ specified value in the specified locale
14
+ - a method +exists+ which takes an attribute and a locale and
15
+ returns an Arel node checking that a value exists for the attribute in the
16
+ specified locale
17
+ - a method +quote+ which quotes the value to be matched
18
+ - an optional method +absent+ which takes an attribute and a locale and returns
19
+ an Arel node checking that the value for the attribute does not exist in the
20
+ specified locale. Defaults to +exists(key, locale).not+.
9
21
 
10
22
  This module avoids a lot of duplication between hstore/json/jsonb/container
11
23
  backend querying code.
@@ -18,18 +30,19 @@ backend querying code.
18
30
 
19
31
  =end
20
32
  module PgQueryMethods
21
- attr_reader :arel_table
33
+ attr_reader :arel_table, :column_affix
22
34
 
23
35
  def initialize(attributes, options)
24
36
  super
25
- @arel_table = options[:model_class].arel_table
37
+ @arel_table = options[:model_class].arel_table
38
+ @column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
26
39
 
27
40
  q = self
28
41
 
29
42
  define_method :where! do |opts, *rest|
30
43
  if i18n_keys = q.extract_attributes(opts)
31
44
  opts = opts.with_indifferent_access
32
- query = q.create_where_query!(opts, i18n_keys)
45
+ query = q.create_query!(opts, i18n_keys)
33
46
 
34
47
  opts.empty? ? super(query) : super(opts, *rest).where(query)
35
48
  else
@@ -46,7 +59,7 @@ backend querying code.
46
59
  define_method :not do |opts, *rest|
47
60
  if i18n_keys = q.extract_attributes(opts)
48
61
  opts = opts.with_indifferent_access
49
- query = q.create_not_query!(opts, i18n_keys)
62
+ query = q.create_query!(opts, i18n_keys, inverse: true)
50
63
 
51
64
  super(opts, *rest).where(query)
52
65
  else
@@ -57,62 +70,85 @@ backend querying code.
57
70
  relation.mobility_where_chain.include(mod)
58
71
  end
59
72
 
60
- # Create +where+ query for options hash, translated keys and arel_table
61
- # @note This is a destructive operation, it will modify +opts+.
73
+ # Create +where+ query for specified key and value
62
74
  #
75
+ # @note This is a destructive operation, it will modify +opts+.
63
76
  # @param [Hash] opts Hash of attribute/value pairs
64
77
  # @param [Array] keys Translated attribute names
78
+ # @option [Boolean] inverse (false) If true, create a +not+ query
79
+ # instead of a +where+ query
65
80
  # @return [Arel::Node] Arel node to pass to +where+
66
- def create_where_query!(opts, keys)
67
- locale = Mobility.locale
81
+ def create_query!(opts, keys, inverse: false)
68
82
  keys.map { |key|
69
- values = opts.delete(key)
70
-
71
- next has_locale(key, locale).not if values.nil?
72
-
73
- Array.wrap(values).map { |value|
74
- value.nil? ?
75
- has_locale(key, locale).not :
76
- matches(key, value, locale)
77
- }.inject(&:or)
83
+ values = Array.wrap(opts.delete(key)).uniq
84
+ send(inverse ? :not_query : :where_query, key, values, Mobility.locale)
78
85
  }.inject(&:and)
79
86
  end
80
87
 
81
- # Create +not+ query for options hash and translated keys
82
- # @note This is a destructive operation, it will modify +opts+.
83
- #
84
- # @param [Hash] opts Hash of attribute/value pairs
85
- # @param [Array] keys Translated attribute names
86
- # @return [Arel::Node] Arel node to pass to +where+
87
- def create_not_query!(opts, keys)
88
- locale = Mobility.locale
89
- keys.map { |key|
90
- values = opts.delete(key)
91
-
92
- Array.wrap(values).map { |value|
93
- matches(key, value, locale).not
94
- }.inject(has_locale(key, locale), &:and)
95
- }.inject(&:and)
88
+ def matches(_key, _locale)
89
+ raise NotImplementedError
96
90
  end
97
91
 
98
- private
92
+ def exists(_key, _locale)
93
+ raise NotImplementedError
94
+ end
99
95
 
100
- def matches(_key, _value, _locale)
96
+ def quote(_value)
101
97
  raise NotImplementedError
102
98
  end
103
99
 
104
- def has_locale(key, locale)
105
- build_infix(:'?', arel_table[key], quote(locale))
100
+ def absent(key, locale)
101
+ exists(key, locale).not
106
102
  end
107
103
 
104
+ private
105
+
108
106
  def build_infix(*args)
109
107
  arel_table.grouping(Arel::Nodes::InfixOperation.new(*args))
110
108
  end
111
109
 
112
- def quote(value)
110
+ def build_quoted(value)
113
111
  Arel::Nodes.build_quoted(value.to_s)
114
112
  end
113
+
114
+ def column_name(attribute)
115
+ column_affix % attribute
116
+ end
117
+
118
+ # Create +where+ query for specified key and values
119
+ #
120
+ # @param [String] key Translated attribute name
121
+ # @param [Array] values Values to match
122
+ # @param [Symbol] locale Locale to query for
123
+ # @return [Arel::Node] Arel node to pass to +where+
124
+ def where_query(key, values, locale)
125
+ nils, vals = values.partition(&:nil?)
126
+
127
+ return absent(key, locale) if vals.empty?
128
+
129
+ node = matches(key, locale)
130
+ vals = vals.map(&method(:quote))
131
+
132
+ query = vals.size == 1 ? node.eq(vals.first) : node.in(vals)
133
+ query = query.or(absent(key, locale)) unless nils.empty?
134
+ query
135
+ end
136
+
137
+ # Create +not+ query for specified key and values
138
+ #
139
+ # @param [String] key Translated attribute name
140
+ # @param [Array] values Values to match
141
+ # @param [Symbol] locale Locale to query for
142
+ # @return [Arel::Node] Arel node to pass to +where+
143
+ def not_query(key, values, locale)
144
+ vals = values.map(&method(:quote))
145
+ node = matches(key, locale)
146
+
147
+ query = vals.size == 1 ? node.eq(vals.first) : node.in(vals)
148
+ query.not.and(exists(key, locale))
149
+ end
115
150
  end
151
+ private_constant :PgQueryMethods
116
152
  end
117
153
  end
118
154
  end
@@ -21,23 +21,30 @@ models. For details see backend-specific subclasses.
21
21
 
22
22
  # @param [ActiveRecord::Relation] relation Relation being extended
23
23
  # @note Only want to define this once, even if multiple QueryMethods
24
- # modules are included, so define it here in extended method
24
+ # modules are included, so include it here into the singleton class.
25
25
  def extended(relation)
26
- unless relation.methods(false).include?(:mobility_where_chain)
27
- relation.define_singleton_method(:mobility_where_chain) do
28
- @mobility_where_chain ||= Class.new(::ActiveRecord::QueryMethods::WhereChain)
29
- end
30
-
31
- relation.define_singleton_method :where do |opts = :chain, *rest|
32
- opts == :chain ? mobility_where_chain.new(spawn) : super(opts, *rest)
33
- end
34
- end
26
+ relation.singleton_class.include WhereChainable
35
27
  end
36
28
 
37
29
  def extract_attributes(opts)
38
30
  opts.is_a?(Hash) && (opts.keys.map(&:to_s) & @attributes).presence
39
31
  end
32
+
33
+ def collapse(value)
34
+ value.is_a?(Array) ? value.uniq : value
35
+ end
36
+ end
37
+
38
+ module WhereChainable
39
+ def where(opts = :chain, *rest)
40
+ opts == :chain ? mobility_where_chain.new(spawn) : super
41
+ end
42
+
43
+ def mobility_where_chain
44
+ @mobility_where_chain ||= Class.new(::ActiveRecord::QueryMethods::WhereChain)
45
+ end
40
46
  end
47
+ private_constant :QueryMethods, :WhereChainable
41
48
  end
42
49
  end
43
50
  end
@@ -11,6 +11,7 @@ Implements {Mobility::Backends::Serialized} backend for ActiveRecord models.
11
11
 
12
12
  @example Define attribute with serialized backend
13
13
  class Post < ActiveRecord::Base
14
+ extend Mobility
14
15
  translates :title, backend: :serialized, format: :yaml
15
16
  end
16
17
 
@@ -42,7 +43,8 @@ Implements {Mobility::Backends::Serialized} backend for ActiveRecord models.
42
43
 
43
44
  setup do |attributes, options|
44
45
  coder = { yaml: YAMLCoder, json: JSONCoder }[options[:format]]
45
- attributes.each { |attribute| serialize attribute, coder }
46
+ column_affix = "#{options[:column_prefix]}%s#{options[:column_suffix]}"
47
+ attributes.each { |attribute| serialize (column_affix % attribute), coder }
46
48
  end
47
49
 
48
50
  setup_query_methods(QueryMethods)
@@ -51,7 +53,7 @@ Implements {Mobility::Backends::Serialized} backend for ActiveRecord models.
51
53
  # Returns column value as a hash
52
54
  # @return [Hash]
53
55
  def translations
54
- model.read_attribute(attribute)
56
+ model.read_attribute(column_name)
55
57
  end
56
58
 
57
59
  %w[yaml json].each do |format|