mobility 0.3.6 → 0.4.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 (51) 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 +21 -0
  5. data/Gemfile +11 -9
  6. data/Gemfile.lock +9 -6
  7. data/README.md +50 -16
  8. data/lib/mobility.rb +18 -4
  9. data/lib/mobility/active_record.rb +14 -7
  10. data/lib/mobility/active_record/uniqueness_validator.rb +17 -3
  11. data/lib/mobility/attributes.rb +39 -16
  12. data/lib/mobility/backends/active_record/column/query_methods.rb +12 -11
  13. data/lib/mobility/backends/active_record/container.rb +116 -0
  14. data/lib/mobility/backends/active_record/container/query_methods.rb +31 -0
  15. data/lib/mobility/backends/active_record/hstore/query_methods.rb +7 -54
  16. data/lib/mobility/backends/active_record/jsonb/query_methods.rb +5 -55
  17. data/lib/mobility/backends/active_record/key_value.rb +13 -9
  18. data/lib/mobility/backends/active_record/key_value/query_methods.rb +6 -6
  19. data/lib/mobility/backends/active_record/pg_query_methods.rb +115 -0
  20. data/lib/mobility/backends/active_record/query_methods.rb +4 -3
  21. data/lib/mobility/backends/active_record/serialized/query_methods.rb +6 -5
  22. data/lib/mobility/backends/active_record/table.rb +18 -3
  23. data/lib/mobility/backends/active_record/table/query_methods.rb +25 -14
  24. data/lib/mobility/backends/container.rb +25 -0
  25. data/lib/mobility/backends/hash_valued.rb +2 -2
  26. data/lib/mobility/backends/null.rb +2 -2
  27. data/lib/mobility/backends/sequel/column.rb +2 -2
  28. data/lib/mobility/backends/sequel/column/query_methods.rb +2 -2
  29. data/lib/mobility/backends/sequel/container.rb +99 -0
  30. data/lib/mobility/backends/sequel/container/query_methods.rb +41 -0
  31. data/lib/mobility/backends/sequel/hstore/query_methods.rb +17 -3
  32. data/lib/mobility/backends/sequel/jsonb/query_methods.rb +17 -3
  33. data/lib/mobility/backends/sequel/key_value.rb +2 -1
  34. data/lib/mobility/backends/sequel/key_value/query_methods.rb +2 -2
  35. data/lib/mobility/backends/sequel/pg_hash.rb +4 -7
  36. data/lib/mobility/backends/sequel/pg_query_methods.rb +85 -0
  37. data/lib/mobility/backends/sequel/query_methods.rb +4 -3
  38. data/lib/mobility/backends/sequel/serialized/query_methods.rb +4 -3
  39. data/lib/mobility/backends/sequel/table.rb +1 -1
  40. data/lib/mobility/backends/sequel/table/query_methods.rb +2 -2
  41. data/lib/mobility/backends/serialized.rb +5 -7
  42. data/lib/mobility/configuration.rb +34 -4
  43. data/lib/mobility/plugins/active_record/dirty.rb +1 -1
  44. data/lib/mobility/plugins/fallbacks.rb +20 -6
  45. data/lib/mobility/sequel/column_changes.rb +2 -5
  46. data/lib/mobility/sequel/hash_initializer.rb +19 -0
  47. data/lib/mobility/util.rb +0 -2
  48. data/lib/mobility/version.rb +1 -1
  49. metadata +11 -4
  50. metadata.gz.sig +0 -0
  51. data/lib/mobility/backends/sequel/postgres_query_methods.rb +0 -41
@@ -11,9 +11,6 @@ models. For details see backend-specific subclasses.
11
11
  # @param [Array<String>] attributes Translated attributes
12
12
  def initialize(attributes, _)
13
13
  @attributes = attributes
14
- @attributes_extractor = lambda do |opts|
15
- opts.is_a?(Hash) && (opts.keys.map(&:to_s) & attributes).presence
16
- end
17
14
  end
18
15
 
19
16
  # @param [ActiveRecord::Relation] relation Relation being extended
@@ -30,6 +27,10 @@ models. For details see backend-specific subclasses.
30
27
  end
31
28
  end
32
29
  end
30
+
31
+ def extract_attributes(opts)
32
+ opts.is_a?(Hash) && (opts.keys.map(&:to_s) & @attributes).presence
33
+ end
33
34
  end
34
35
  end
35
36
  end
@@ -3,23 +3,24 @@ require "mobility/backends/active_record/query_methods"
3
3
  module Mobility
4
4
  module Backends
5
5
  class ActiveRecord::Serialized::QueryMethods < ActiveRecord::QueryMethods
6
+ include Serialized
7
+
6
8
  def initialize(attributes, _)
7
9
  super
8
- attributes_extractor = @attributes_extractor
9
- opts_checker = @opts_checker = Backends::Serialized.attr_checker(attributes_extractor)
10
+ q = self
10
11
 
11
12
  define_method :where! do |opts, *rest|
12
- opts_checker.call(opts) || super(opts, *rest)
13
+ q.check_opts(opts) || super(opts, *rest)
13
14
  end
14
15
  end
15
16
 
16
17
  def extended(relation)
17
18
  super
18
- opts_checker = @opts_checker
19
+ q = self
19
20
 
20
21
  mod = Module.new do
21
22
  define_method :not do |opts, *rest|
22
- opts_checker.call(opts) || super(opts, *rest)
23
+ q.check_opts(opts) || super(opts, *rest)
23
24
  end
24
25
  end
25
26
  relation.mobility_where_chain.include(mod)
@@ -20,7 +20,7 @@ columns to that table.
20
20
 
21
21
  @example Model with table backend
22
22
  class Post < ActiveRecord::Base
23
- translates :title, backend: :table, association_name: :translations
23
+ translates :title, backend: :table
24
24
  end
25
25
 
26
26
  post = Post.create(title: "foo")
@@ -139,16 +139,20 @@ columns to that table.
139
139
  inverse_of: association_name,
140
140
  touch: true
141
141
 
142
+ before_save { mobility_destroy_empty_table_translations(association_name) }
143
+
142
144
  module_name = "MobilityArTable#{association_name.to_s.camelcase}"
143
145
  unless const_defined?(module_name)
144
- callback_methods = Module.new do
146
+ dupable = Module.new do
145
147
  define_method :initialize_dup do |source|
146
148
  super(source)
147
149
  self.send("#{association_name}=", source.send(association_name).map(&:dup))
148
150
  end
149
151
  end
150
- include const_set(module_name, callback_methods)
152
+ include const_set(module_name, dupable)
151
153
  end
154
+
155
+ include DestroyEmptyTranslations
152
156
  end
153
157
 
154
158
  setup_query_methods(QueryMethods)
@@ -158,6 +162,17 @@ columns to that table.
158
162
  translation ||= translations.build(locale: locale)
159
163
  translation
160
164
  end
165
+
166
+ module DestroyEmptyTranslations
167
+ private
168
+
169
+ def mobility_destroy_empty_table_translations(association_name)
170
+ send(association_name).each do |t|
171
+ attrs = t.attribute_names & self.class.translated_attribute_names
172
+ send(association_name).destroy(t) if attrs.map(&t.method(:send)).none?
173
+ end
174
+ end
175
+ end
161
176
  end
162
177
  end
163
178
  end
@@ -21,13 +21,13 @@ module Mobility
21
21
 
22
22
  def extended(relation)
23
23
  super
24
- association_name = @association_name
25
- attributes_extractor = @attributes_extractor
26
- translation_class = @translation_class
24
+ association_name = @association_name
25
+ translation_class = @translation_class
26
+ q = self
27
27
 
28
28
  mod = Module.new do
29
29
  define_method :not do |opts, *rest|
30
- if i18n_keys = attributes_extractor.call(opts)
30
+ if i18n_keys = q.extract_attributes(opts)
31
31
  opts = opts.with_indifferent_access
32
32
  i18n_keys.each { |attr| opts["#{translation_class.table_name}.#{attr}"] = opts.delete(attr) }
33
33
  super(opts, *rest).send("join_#{association_name}")
@@ -43,8 +43,7 @@ module Mobility
43
43
 
44
44
  def define_join_method(association_name, translation_class, foreign_key: nil, table_name: nil, **)
45
45
  define_method :"join_#{association_name}" do |**options|
46
- return self if (@__mobility_table_joined || []).include?(table_name)
47
- (@__mobility_table_joined ||= []) << table_name
46
+ return self if joins_values.any? { |v| v.left.name == table_name.to_s }
48
47
  t = translation_class.arel_table
49
48
  m = arel_table
50
49
  join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin
@@ -55,23 +54,31 @@ module Mobility
55
54
  end
56
55
 
57
56
  def define_query_methods(association_name, translation_class, **)
58
- attributes_extractor = @attributes_extractor
57
+ q = self
59
58
 
60
59
  # Note that Mobility will try to use inner/outer joins appropriate to the query,
61
60
  # so for example:
62
61
  #
63
- # Article.where(title: nil, content: nil) #=> OUTER JOIN (all nils)
64
- # Article.where(title: "foo", content: nil) #=> INNER JOIN (one non-nil)
62
+ # Article.where(title: nil, content: nil) #=> OUTER JOIN (all nils)
63
+ # Article.where(title: "foo", content: nil) #=> INNER JOIN (one non-nil)
65
64
  #
66
65
  # In the first case, if we are in (say) the "en" locale, then we should match articles
67
66
  # that have *no* article_translations with English locales (since no translation is
68
67
  # equivalent to a nil value). If we used an inner join in the first case, an article
69
68
  # with no English translations would be filtered out, so we use an outer join.
70
69
  #
71
- # However, if you call `where` multiple times, you may end up with an outer join
72
- # when a (faster) inner join would have worked fine:
70
+ # When deciding whether to use an outer or inner join, array-valued
71
+ # conditions are treated as nil if they have any values.
73
72
  #
74
- # Article.where(title: nil).where(content: "foo") #=> OUTER JOIN
73
+ # Article.where(title: nil, content: ["foo", nil]) #=> OUTER JOIN (all nil or array with nil)
74
+ # Article.where(title: "foo", content: ["foo", nil]) #=> INNER JOIN (one non-nil)
75
+ # Article.where(title: ["foo", "bar"], content: ["foo", nil]) #=> INNER JOIN (one non-nil array)
76
+ #
77
+ # Note that if you call `where` multiple times, you may end up with an
78
+ # outer join when a (faster) inner join would have worked fine:
79
+ #
80
+ # Article.where(title: nil).where(content: "foo") #=> OUTER JOIN
81
+ # Article.where(title: [nil, "foo"]).where(content: "foo") #=> OUTER JOIN
75
82
  #
76
83
  # In this case, we are searching for a match on the article_translations table
77
84
  # which has a NULL title and a content equal to "foo". Since we need a positive
@@ -85,9 +92,13 @@ module Mobility
85
92
  # Article.where(title: nil, content: "foo") #=> INNER JOIN
86
93
  #
87
94
  define_method :where! do |opts, *rest|
88
- if i18n_keys = attributes_extractor.call(opts)
95
+ if i18n_keys = q.extract_attributes(opts)
89
96
  opts = opts.with_indifferent_access
90
- options = { outer_join: i18n_keys.all? { |attr| opts[attr].nil? } }
97
+ options = {
98
+ # We only need an OUTER JOIN if every value is either nil, or an
99
+ # array with at least one nil value.
100
+ outer_join: opts.values_at(*i18n_keys).compact.all? { |v| !Array.wrap(v).all? }
101
+ }
91
102
  i18n_keys.each { |attr| opts["#{translation_class.table_name}.#{attr}"] = opts.delete(attr) }
92
103
  super(opts, *rest).send("join_#{association_name}", options)
93
104
  else
@@ -0,0 +1,25 @@
1
+ module Mobility
2
+ module Backends
3
+
4
+ =begin
5
+
6
+ Stores translations for multiple attributes on a single shared Postgres jsonb
7
+ column (called a "container").
8
+
9
+ ==Backend Options
10
+
11
+ ===+column_name+
12
+
13
+ Name of the column for the translations container (where translations are
14
+ stored).
15
+
16
+ @see Mobility::Backends::ActiveRecord::Container
17
+ @see Mobility::Backends::Sequel::Container
18
+ @see https://www.postgresql.org/docs/current/static/datatype-json.html PostgreSQL Documentation for JSON Types
19
+
20
+ =end
21
+ module Container
22
+ extend Backend::OrmDelegator
23
+ end
24
+ end
25
+ end
@@ -10,12 +10,12 @@ Defines read and write methods that access the value at a key with value
10
10
  # @!group Backend Accessors
11
11
  #
12
12
  # @!macro backend_reader
13
- def read(locale, _ = {})
13
+ def read(locale, _options = nil)
14
14
  translations[locale]
15
15
  end
16
16
 
17
17
  # @!macro backend_writer
18
- def write(locale, value, _ = {})
18
+ def write(locale, value, _options = nil)
19
19
  translations[locale] = value
20
20
  end
21
21
  # @!endgroup
@@ -10,10 +10,10 @@ Backend which does absolutely nothing. Mostly for testing purposes.
10
10
 
11
11
  # @!group Backend Accessors
12
12
  # @return [NilClass]
13
- def read(_, _ = {}); end
13
+ def read(_locale, _options = nil); end
14
14
 
15
15
  # @return [NilClass]
16
- def write(_, _, _ = {}); end
16
+ def write(_locale, _value, _options = nil); end
17
17
  # @!endgroup
18
18
 
19
19
  # @!group Backend Configuration
@@ -16,14 +16,14 @@ Implements the {Mobility::Backends::Column} backend for Sequel models.
16
16
 
17
17
  # @!group Backend Accessors
18
18
  # @!macro backend_reader
19
- def read(locale, _ = {})
19
+ def read(locale, _options = nil)
20
20
  column = column(locale)
21
21
  model[column] if model.columns.include?(column)
22
22
  end
23
23
 
24
24
  # @!group Backend Accessors
25
25
  # @!macro backend_writer
26
- def write(locale, value, _ = {})
26
+ def write(locale, value, _options = nil)
27
27
  column = column(locale)
28
28
  model[column] = value if model.columns.include?(column)
29
29
  end
@@ -5,11 +5,11 @@ module Mobility
5
5
  class Sequel::Column::QueryMethods < Sequel::QueryMethods
6
6
  def initialize(attributes, _)
7
7
  super
8
- attributes_extractor = @attributes_extractor
8
+ q = self
9
9
 
10
10
  %w[exclude or where].each do |method_name|
11
11
  define_method method_name do |*conds, &block|
12
- if keys = attributes_extractor.call(conds.first)
12
+ if keys = q.extract_attributes(conds.first)
13
13
  cond = conds.first.dup
14
14
  keys.each { |attr| cond[Column.column_name_for(attr)] = cond.delete(attr) }
15
15
  super(cond, &block)
@@ -0,0 +1,99 @@
1
+ module Mobility
2
+ module Backends
3
+ =begin
4
+
5
+ Implements the {Mobility::Backends::Container} backend for Sequel models.
6
+
7
+ =end
8
+ class Sequel::Container
9
+ include Sequel
10
+
11
+ require 'mobility/backends/sequel/container/query_methods'
12
+
13
+ # @return [Symbol] name of container column
14
+ attr_reader :column_name
15
+
16
+ # @!macro backend_constructor
17
+ # @option options [Symbol] column_name Name of container column
18
+ def initialize(model, attribute, options = {})
19
+ super
20
+ @column_name = options[:column_name]
21
+ end
22
+
23
+ # @!group Backend Accessors
24
+ #
25
+ # @note Translation may be a string, integer, boolean, hash or array
26
+ # since value is stored on a JSON hash.
27
+ # @param [Symbol] locale Locale to read
28
+ # @param [Hash] options
29
+ # @return [String,Integer,Boolean] Value of translation
30
+ def read(locale, _ = nil)
31
+ model_translations(locale)[attribute]
32
+ end
33
+
34
+ # @note Translation may be a string, integer, boolean, hash or array
35
+ # since value is stored on a JSON hash.
36
+ # @param [Symbol] locale Locale to write
37
+ # @param [String,Integer,Boolean] value Value to write
38
+ # @param [Hash] options
39
+ # @return [String,Integer,Boolean] Updated value
40
+ def write(locale, value, _ = nil)
41
+ set_attribute_translation(locale, value)
42
+ model_translations(locale)[attribute]
43
+ end
44
+ # @!endgroup
45
+ #
46
+ # @!group Backend Configuration
47
+ # @option options [Symbol] column_name (:translations) Name of column on which to store translations
48
+ def self.configure(options)
49
+ options[:column_name] ||= :translations
50
+ end
51
+ # @!endgroup
52
+ #
53
+ # @!macro backend_iterator
54
+ def each_locale
55
+ model[column_name].each do |l, _|
56
+ yield l.to_sym unless read(l).nil?
57
+ end
58
+ end
59
+
60
+ setup do |attributes, options|
61
+ column_name = options[:column_name]
62
+ before_validation = Module.new do
63
+ define_method :before_validation do
64
+ self[column_name].each do |k, v|
65
+ v.delete_if { |_locale, translation| Util.blank?(translation) }
66
+ self[column_name].delete(k) if v.empty?
67
+ end
68
+ super()
69
+ end
70
+ end
71
+ include before_validation
72
+ include Mobility::Sequel::HashInitializer.new(column_name)
73
+
74
+ plugin :defaults_setter
75
+ attributes.each { |attribute| default_values[attribute.to_sym] = {} }
76
+ end
77
+
78
+ setup_query_methods(QueryMethods)
79
+
80
+ private
81
+
82
+ def model_translations(locale)
83
+ model[column_name][locale.to_s] ||= {}
84
+ end
85
+
86
+ def set_attribute_translation(locale, value)
87
+ translations = model[column_name] || {}
88
+ translations[locale.to_s] ||= {}
89
+ # Explicitly mark translations column as changed if value changed,
90
+ # otherwise Sequel will not detect it.
91
+ # TODO: Find a cleaner/easier way to do this.
92
+ if translations[locale.to_s][attribute] != value
93
+ model.instance_variable_set(:@changed_columns, model.changed_columns | [column_name])
94
+ end
95
+ translations[locale.to_s][attribute] = value
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,41 @@
1
+ require "mobility/backends/sequel/pg_query_methods"
2
+ require "mobility/backends/sequel/query_methods"
3
+
4
+ Sequel.extension :pg_json, :pg_json_ops
5
+
6
+ module Mobility
7
+ module Backends
8
+ class Sequel::Container::QueryMethods < Sequel::QueryMethods
9
+ include Sequel::PgQueryMethods
10
+ attr_reader :column_name
11
+
12
+ def initialize(attributes, options)
13
+ super
14
+ column_name = @column_name = options[:column_name]
15
+
16
+ define_query_methods
17
+
18
+ attributes.each do |attribute|
19
+ define_method :"first_by_#{attribute}" do |value|
20
+ where(::Sequel.pg_jsonb_op(column_name)[Mobility.locale.to_s].contains({ attribute => value })).
21
+ select_all(model.table_name).first
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def contains_value(key, value, locale)
29
+ build_op(column_name)[locale].contains({ key.to_s => value }.to_json)
30
+ end
31
+
32
+ def has_locale(key, locale)
33
+ build_op(column_name).has_key?(locale) & build_op(column_name)[locale].has_key?(key.to_s)
34
+ end
35
+
36
+ def build_op(key)
37
+ ::Sequel.pg_jsonb_op(key)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,4 +1,4 @@
1
- require 'mobility/backends/sequel/postgres_query_methods'
1
+ require 'mobility/backends/sequel/pg_query_methods'
2
2
  require "mobility/backends/sequel/query_methods"
3
3
 
4
4
  Sequel.extension :pg_hstore, :pg_hstore_ops
@@ -6,12 +6,12 @@ Sequel.extension :pg_hstore, :pg_hstore_ops
6
6
  module Mobility
7
7
  module Backends
8
8
  class Sequel::Hstore::QueryMethods < Sequel::QueryMethods
9
- include PostgresQueryMethods
9
+ include Sequel::PgQueryMethods
10
10
 
11
11
  def initialize(attributes, _)
12
12
  super
13
13
 
14
- define_query_methods("hstore")
14
+ define_query_methods
15
15
 
16
16
  attributes.each do |attribute|
17
17
  define_method :"first_by_#{attribute}" do |value|
@@ -20,6 +20,20 @@ module Mobility
20
20
  end
21
21
  end
22
22
  end
23
+
24
+ private
25
+
26
+ def contains_value(key, value, locale)
27
+ build_op(key).contains(locale => value.to_s)
28
+ end
29
+
30
+ def has_locale(key, locale)
31
+ build_op(key).has_key?(locale)
32
+ end
33
+
34
+ def build_op(key)
35
+ ::Sequel.hstore_op(key)
36
+ end
23
37
  end
24
38
  end
25
39
  end