mobility 0.3.6 → 0.4.0

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