mobility 0.0.1 → 0.1.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +19 -0
  3. data/Gemfile.lock +153 -0
  4. data/Guardfile +70 -0
  5. data/README.md +603 -13
  6. data/Rakefile +42 -0
  7. data/lib/generators/mobility/install_generator.rb +45 -0
  8. data/lib/generators/mobility/templates/create_string_translations.rb +15 -0
  9. data/lib/generators/mobility/templates/create_text_translations.rb +15 -0
  10. data/lib/mobility.rb +203 -2
  11. data/lib/mobility/active_model.rb +6 -0
  12. data/lib/mobility/active_model/attribute_methods.rb +27 -0
  13. data/lib/mobility/active_model/backend_resetter.rb +26 -0
  14. data/lib/mobility/active_record.rb +39 -0
  15. data/lib/mobility/active_record/backend_resetter.rb +26 -0
  16. data/lib/mobility/active_record/model_translation.rb +14 -0
  17. data/lib/mobility/active_record/string_translation.rb +7 -0
  18. data/lib/mobility/active_record/text_translation.rb +7 -0
  19. data/lib/mobility/active_record/translation.rb +14 -0
  20. data/lib/mobility/attributes.rb +210 -0
  21. data/lib/mobility/backend.rb +152 -0
  22. data/lib/mobility/backend/active_model.rb +7 -0
  23. data/lib/mobility/backend/active_model/dirty.rb +84 -0
  24. data/lib/mobility/backend/active_record.rb +13 -0
  25. data/lib/mobility/backend/active_record/column.rb +52 -0
  26. data/lib/mobility/backend/active_record/column/query_methods.rb +40 -0
  27. data/lib/mobility/backend/active_record/hash_valued.rb +58 -0
  28. data/lib/mobility/backend/active_record/hstore.rb +36 -0
  29. data/lib/mobility/backend/active_record/hstore/query_methods.rb +53 -0
  30. data/lib/mobility/backend/active_record/jsonb.rb +43 -0
  31. data/lib/mobility/backend/active_record/jsonb/query_methods.rb +53 -0
  32. data/lib/mobility/backend/active_record/key_value.rb +126 -0
  33. data/lib/mobility/backend/active_record/key_value/query_methods.rb +63 -0
  34. data/lib/mobility/backend/active_record/query_methods.rb +36 -0
  35. data/lib/mobility/backend/active_record/serialized.rb +93 -0
  36. data/lib/mobility/backend/active_record/serialized/query_methods.rb +32 -0
  37. data/lib/mobility/backend/active_record/table.rb +197 -0
  38. data/lib/mobility/backend/active_record/table/query_methods.rb +91 -0
  39. data/lib/mobility/backend/cache.rb +110 -0
  40. data/lib/mobility/backend/column.rb +52 -0
  41. data/lib/mobility/backend/dirty.rb +28 -0
  42. data/lib/mobility/backend/fallbacks.rb +89 -0
  43. data/lib/mobility/backend/hstore.rb +21 -0
  44. data/lib/mobility/backend/jsonb.rb +21 -0
  45. data/lib/mobility/backend/key_value.rb +71 -0
  46. data/lib/mobility/backend/null.rb +24 -0
  47. data/lib/mobility/backend/orm_delegator.rb +33 -0
  48. data/lib/mobility/backend/sequel.rb +14 -0
  49. data/lib/mobility/backend/sequel/column.rb +40 -0
  50. data/lib/mobility/backend/sequel/column/query_methods.rb +24 -0
  51. data/lib/mobility/backend/sequel/dirty.rb +54 -0
  52. data/lib/mobility/backend/sequel/hash_valued.rb +51 -0
  53. data/lib/mobility/backend/sequel/hstore.rb +36 -0
  54. data/lib/mobility/backend/sequel/hstore/query_methods.rb +42 -0
  55. data/lib/mobility/backend/sequel/jsonb.rb +43 -0
  56. data/lib/mobility/backend/sequel/jsonb/query_methods.rb +42 -0
  57. data/lib/mobility/backend/sequel/key_value.rb +139 -0
  58. data/lib/mobility/backend/sequel/key_value/query_methods.rb +48 -0
  59. data/lib/mobility/backend/sequel/query_methods.rb +22 -0
  60. data/lib/mobility/backend/sequel/serialized.rb +133 -0
  61. data/lib/mobility/backend/sequel/serialized/query_methods.rb +20 -0
  62. data/lib/mobility/backend/sequel/table.rb +149 -0
  63. data/lib/mobility/backend/sequel/table/query_methods.rb +48 -0
  64. data/lib/mobility/backend/serialized.rb +53 -0
  65. data/lib/mobility/backend/table.rb +93 -0
  66. data/lib/mobility/backend_resetter.rb +44 -0
  67. data/lib/mobility/configuration.rb +31 -0
  68. data/lib/mobility/core_ext/nil.rb +10 -0
  69. data/lib/mobility/core_ext/object.rb +19 -0
  70. data/lib/mobility/core_ext/string.rb +16 -0
  71. data/lib/mobility/instance_methods.rb +34 -0
  72. data/lib/mobility/orm.rb +4 -0
  73. data/lib/mobility/sequel.rb +26 -0
  74. data/lib/mobility/sequel/backend_resetter.rb +26 -0
  75. data/lib/mobility/sequel/column_changes.rb +29 -0
  76. data/lib/mobility/sequel/model_translation.rb +20 -0
  77. data/lib/mobility/sequel/string_translation.rb +7 -0
  78. data/lib/mobility/sequel/text_translation.rb +7 -0
  79. data/lib/mobility/sequel/translation.rb +53 -0
  80. data/lib/mobility/translates.rb +75 -0
  81. data/lib/mobility/wrapper.rb +31 -0
  82. metadata +152 -12
  83. data/.gitignore +0 -9
  84. data/.rspec +0 -2
  85. data/.travis.yml +0 -5
  86. data/bin/console +0 -14
  87. data/bin/setup +0 -8
  88. data/mobility.gemspec +0 -32
@@ -0,0 +1,63 @@
1
+ module Mobility
2
+ module Backend
3
+ class ActiveRecord::KeyValue::QueryMethods < ActiveRecord::QueryMethods
4
+ def initialize(attributes, **options)
5
+ super
6
+ association_name, translations_class = options[:association_name], options[:class_name]
7
+ @association_name = association_name
8
+ attributes_extractor = @attributes_extractor
9
+
10
+ define_method :"join_#{association_name}" do |*attributes, **options|
11
+ attributes.inject(self) do |relation, attribute|
12
+ t = translations_class.arel_table.alias(:"#{attribute}_#{association_name}")
13
+ m = arel_table
14
+ join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin
15
+ relation.joins(m.join(t, join_type).
16
+ on(t[:key].eq(attribute).
17
+ and(t[:locale].eq(Mobility.locale).
18
+ and(t[:translatable_type].eq(name).
19
+ and(t[:translatable_id].eq(m[:id]))))).join_sources)
20
+ end
21
+ end
22
+
23
+ define_method :where! do |opts, *rest|
24
+ if i18n_keys = attributes_extractor.call(opts)
25
+ opts = opts.with_indifferent_access
26
+ i18n_nulls = i18n_keys.select { |key| opts[key].nil? }
27
+ i18n_keys.each { |attr| opts["#{attr}_#{association_name}"] = { value: opts.delete(attr) }}
28
+ super(opts, *rest).
29
+ send("join_#{association_name}", *(i18n_keys - i18n_nulls)).
30
+ send("join_#{association_name}", *i18n_nulls, outer_join: true)
31
+ else
32
+ super(opts, *rest)
33
+ end
34
+ end
35
+
36
+ attributes.each do |attribute|
37
+ define_method :"find_by_#{attribute}" do |value|
38
+ find_by(attribute.to_sym => value)
39
+ end
40
+ end
41
+ end
42
+
43
+ def extended(relation)
44
+ super
45
+ association_name = @association_name
46
+ attributes_extractor = @attributes_extractor
47
+
48
+ mod = Module.new do
49
+ define_method :not do |opts, *rest|
50
+ if i18n_keys = attributes_extractor.call(opts)
51
+ opts = opts.with_indifferent_access
52
+ i18n_keys.each { |attr| opts["#{attr}_#{association_name}"] = { value: opts.delete(attr) }}
53
+ super(opts, *rest).send(:"join_#{association_name}", *i18n_keys)
54
+ else
55
+ super(opts, *rest)
56
+ end
57
+ end
58
+ end
59
+ relation.model.mobility_where_chain.prepend(mod)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,36 @@
1
+ module Mobility
2
+ module Backend
3
+ module ActiveRecord
4
+ =begin
5
+
6
+ Defines query method overrides to handle translated attributes for ActiveRecord
7
+ models. For details see backend-specific subclasses.
8
+
9
+ =end
10
+ class QueryMethods < Module
11
+ # @param [Array<String>] attributes Translated attributes
12
+ # @param [Hash] options Backend options
13
+ def initialize(attributes, **options)
14
+ @attributes = attributes
15
+ @attributes_extractor = lambda do |opts|
16
+ opts.is_a?(Hash) && (opts.keys.map(&:to_s) & attributes).presence
17
+ end
18
+ end
19
+
20
+ # @param [ActiveRecord::Relation] relation Relation being extended
21
+ def extended(relation)
22
+ model_class = relation.model
23
+ unless model_class.respond_to?(:mobility_where_chain)
24
+ model_class.define_singleton_method(:mobility_where_chain) do
25
+ @mobility_where_chain ||= Class.new(::ActiveRecord::QueryMethods::WhereChain)
26
+ end
27
+
28
+ relation.define_singleton_method :where do |opts = :chain, *rest|
29
+ opts == :chain ? mobility_where_chain.new(spawn) : super(opts, *rest)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,93 @@
1
+ module Mobility
2
+ module Backend
3
+ =begin
4
+
5
+ Implements {Mobility::Backend::Serialized} backend for ActiveRecord models.
6
+
7
+ @example Define attribute with serialized backend
8
+ class Post < ActiveRecord::Base
9
+ translates :title, backend: :serialized, format: :yaml
10
+ end
11
+
12
+ @example Read and write attribute translations
13
+ post = Post.create(title: "foo")
14
+ post.title
15
+ #=> "foo"
16
+ Mobility.locale = :ja
17
+ post.title = "あああ"
18
+ post.save
19
+ post.read_attribute(:title) # get serialized value
20
+ #=> {:en=>"foo", :ja=>"あああ"}
21
+
22
+ =end
23
+ class ActiveRecord::Serialized
24
+ include Backend
25
+
26
+ autoload :QueryMethods, 'mobility/backend/active_record/serialized/query_methods'
27
+
28
+ # @!group Backend Accessors
29
+ #
30
+ # @!macro backend_reader
31
+ def read(locale, **options)
32
+ translations[locale]
33
+ end
34
+
35
+ # @!macro backend_reader
36
+ def write(locale, value, **options)
37
+ translations[locale] = value
38
+ end
39
+ # @!endgroup
40
+
41
+ # @!group Backend Configuration
42
+ # @option options [Symbol] format (:yaml) Serialization format
43
+ # @raise [ArgumentError] if a format other than +:yaml+ or +:json+ is passed in
44
+ def self.configure!(options)
45
+ options[:format] ||= :yaml
46
+ options[:format] = options[:format].downcase.to_sym
47
+ raise ArgumentError, "Serialized backend only supports yaml or json formats." unless [:yaml, :json].include?(options[:format])
48
+ end
49
+ # @!endgroup
50
+
51
+ setup do |attributes, options|
52
+ coder = { yaml: YAMLCoder, json: JSONCoder }[options[:format]]
53
+ attributes.each { |attribute| serialize attribute, coder }
54
+
55
+ extension = Module.new do
56
+ define_method :i18n do
57
+ @mobility_scope ||= super().extending(QueryMethods.new(attributes, options))
58
+ end
59
+ end
60
+ extend extension
61
+ end
62
+
63
+ # @!group Cache Methods
64
+ # Returns column value as a hash
65
+ # @return [Hash]
66
+ def translations
67
+ model.read_attribute(attribute)
68
+ end
69
+ alias_method :new_cache, :translations
70
+
71
+ # @return [Boolean]
72
+ def write_to_cache?
73
+ true
74
+ end
75
+ # @!endgroup
76
+
77
+ %w[yaml json].each do |format|
78
+ class_eval <<-EOM, __FILE__, __LINE__ + 1
79
+ class #{format.upcase}Coder
80
+ def self.dump(obj)
81
+ Serialized.serializer_for(:#{format}).call(obj)
82
+ end
83
+
84
+ def self.load(obj)
85
+ return {} if obj.nil?
86
+ Serialized.deserializer_for(:#{format}).call(obj)
87
+ end
88
+ end
89
+ EOM
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ module Mobility
2
+ module Backend
3
+ class ActiveRecord::Serialized::QueryMethods < ActiveRecord::QueryMethods
4
+ def initialize(attributes, **options)
5
+ super
6
+ attributes_extractor = @attributes_extractor
7
+ opts_checker = @opts_checker = lambda do |opts|
8
+ if keys = attributes_extractor.call(opts)
9
+ raise ArgumentError,
10
+ "You cannot query on mobility attributes translated with the Serialized backend (#{keys.join(", ")})."
11
+ end
12
+ end
13
+
14
+ define_method :where! do |opts, *rest|
15
+ opts_checker.call(opts) || super(opts, *rest)
16
+ end
17
+ end
18
+
19
+ def extended(relation)
20
+ super
21
+ opts_checker = @opts_checker
22
+
23
+ mod = Module.new do
24
+ define_method :not do |opts, *rest|
25
+ opts_checker.call(opts) || super(opts, *rest)
26
+ end
27
+ end
28
+ relation.model.mobility_where_chain.prepend(mod)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,197 @@
1
+ module Mobility
2
+ module Backend
3
+ =begin
4
+
5
+ Implements the {Mobility::Backend::Table} backend for ActiveRecord models.
6
+
7
+ @example Model with table backend
8
+ class Post < ActiveRecord::Base
9
+ translates :title, backend: :table, association_name: :translations
10
+ end
11
+
12
+ post = Post.create(title: "foo")
13
+ #<Post:0x00... id: 1>
14
+
15
+ post.title
16
+ #=> "foo"
17
+
18
+ post.translations
19
+ #=> [#<Post::Translation:0x00...
20
+ # id: 1,
21
+ # locale: "en",
22
+ # post_id: 1,
23
+ # title: "foo">]
24
+
25
+ Post::Translation.first
26
+ #=> #<Post::Translation:0x00...
27
+ # id: 1,
28
+ # locale: "en",
29
+ # post_id: 1,
30
+ # title: "foo">
31
+
32
+ @example Model with multiple translation tables
33
+ class Post < ActiveRecord::Base
34
+ translates :title, backend: :table, table_name: :post_title_translations, association_name: :title_translations
35
+ translates :content, backend: :table, table_name: :post_content_translations, association_name: :content_translations
36
+ end
37
+
38
+ post = Post.create(title: "foo", content: "bar")
39
+ #<Post:0x00... id: 1>
40
+
41
+ post.title
42
+ #=> "foo"
43
+
44
+ post.content
45
+ #=> "bar"
46
+
47
+ post.title_translations
48
+ #=> [#<Post::TitleTranslation:0x00...
49
+ # id: 1,
50
+ # locale: "en",
51
+ # post_id: 1,
52
+ # title: "foo">]
53
+
54
+ post.content_translations
55
+ #=> [#<Post::ContentTranslation:0x00...
56
+ # id: 1,
57
+ # locale: "en",
58
+ # post_id: 1,
59
+ # content: "bar">]
60
+
61
+ Post::TitleTranslation.first
62
+ #=> #<Post::TitleTranslation:0x00...
63
+ # id: 1,
64
+ # locale: "en",
65
+ # post_id: 1,
66
+ # title: "foo">
67
+
68
+ Post::ContentTranslation.first
69
+ #=> #<Post::ContentTranslation:0x00...
70
+ # id: 1,
71
+ # locale: "en",
72
+ # post_id: 1,
73
+ # title: "bar">
74
+ =end
75
+ class ActiveRecord::Table
76
+ include Backend
77
+
78
+ autoload :QueryMethods, 'mobility/backend/active_record/table/query_methods'
79
+
80
+ # @return [Symbol] name of the association method
81
+ attr_reader :association_name
82
+
83
+ # @!macro backend_constructor
84
+ # @option options [Symbol] association_name Name of association
85
+ def initialize(model, attribute, **options)
86
+ super
87
+ @association_name = options[:association_name]
88
+ end
89
+
90
+ # @!group Backend Accessors
91
+ # @!macro backend_reader
92
+ def read(locale, **options)
93
+ translation_for(locale).send(attribute)
94
+ end
95
+
96
+ # @!macro backend_reader
97
+ def write(locale, value, **options)
98
+ translation_for(locale).tap { |t| t.send("#{attribute}=", value) }.send(attribute)
99
+ end
100
+ # @!endgroup
101
+
102
+ # @!group Backend Configuration
103
+ # @option options [Symbol] association_name (:mobility_model_translations)
104
+ # Name of association method
105
+ # @option options [Symbol] table_name Name of translation table
106
+ # @option options [Symbol] foreign_key Name of foreign key
107
+ # @option options [Symbol] subclass_name (:Translation) Name of subclass
108
+ # to append to model class to generate translation class
109
+ def self.configure!(options)
110
+ table_name = options[:model_class].table_name
111
+ options[:table_name] ||= "#{table_name.singularize}_translations"
112
+ options[:foreign_key] ||= table_name.downcase.singularize.camelize.foreign_key
113
+ if (association_name = options[:association_name]).present?
114
+ options[:subclass_name] ||= association_name.to_s.singularize.camelize
115
+ else
116
+ options[:association_name] = :mobility_model_translations
117
+ options[:subclass_name] ||= :Translation
118
+ end
119
+ %i[foreign_key association_name subclass_name].each { |key| options[key] = options[key].to_sym }
120
+ end
121
+ # @!endgroup
122
+
123
+ setup do |attributes, options|
124
+ association_name = options[:association_name]
125
+ subclass_name = options[:subclass_name]
126
+
127
+ attr_accessor :"__#{association_name}_cache"
128
+
129
+ translation_class =
130
+ if self.const_defined?(subclass_name, false)
131
+ const_get(subclass_name, false)
132
+ else
133
+ const_set(subclass_name, Class.new(Mobility::ActiveRecord::ModelTranslation))
134
+ end
135
+
136
+ translation_class.table_name = options[:table_name]
137
+
138
+ has_many association_name,
139
+ class_name: translation_class.name,
140
+ foreign_key: options[:foreign_key],
141
+ dependent: :destroy,
142
+ autosave: true,
143
+ inverse_of: :translated_model
144
+
145
+ translation_class.belongs_to :translated_model,
146
+ class_name: name,
147
+ foreign_key: options[:foreign_key],
148
+ inverse_of: association_name
149
+
150
+ query_methods = Module.new do
151
+ define_method :i18n do
152
+ @mobility_scope ||= super().extending(QueryMethods.new(attributes, options))
153
+ end
154
+ end
155
+ extend query_methods
156
+ end
157
+
158
+ # @!group Cache Methods
159
+ # @return [Table::TranslationsCache]
160
+ def new_cache
161
+ reset_model_cache unless model_cache
162
+ model_cache.for(attribute)
163
+ end
164
+
165
+ # @return [Boolean]
166
+ def write_to_cache?
167
+ true
168
+ end
169
+
170
+ def clear_cache
171
+ model_cache.try(:clear)
172
+ end
173
+ # @!endgroup
174
+
175
+ private
176
+
177
+ def translation_for(locale)
178
+ translation = translations.find { |t| t.locale == locale.to_s }
179
+ translation ||= translations.build(locale: locale)
180
+ translation
181
+ end
182
+
183
+ def translations
184
+ model.send(association_name)
185
+ end
186
+
187
+ def model_cache
188
+ model.send(:"__#{association_name}_cache")
189
+ end
190
+
191
+ def reset_model_cache
192
+ model.send(:"__#{association_name}_cache=",
193
+ Table::TranslationsCache.new { |locale| translation_for(locale) })
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,91 @@
1
+ module Mobility
2
+ module Backend
3
+ class ActiveRecord::Table::QueryMethods < ActiveRecord::QueryMethods
4
+ def initialize(attributes, **options)
5
+ super
6
+ association_name = options[:association_name]
7
+ foreign_key = options[:foreign_key]
8
+ @association_name = association_name
9
+ attributes_extractor = @attributes_extractor
10
+ translation_class = options[:model_class].const_get(options[:subclass_name])
11
+ @translation_class = translation_class
12
+ table_name = options[:table_name]
13
+
14
+ define_method :"join_#{association_name}" do |**options|
15
+ return self if (@__mobility_table_joined || []).include?(table_name)
16
+ (@__mobility_table_joined ||= []) << table_name
17
+ t = translation_class.arel_table
18
+ m = arel_table
19
+ join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin
20
+ joins(m.join(t, join_type).
21
+ on(t[foreign_key].eq(m[:id]).
22
+ and(t[:locale].eq(Mobility.locale))).join_sources)
23
+ end
24
+
25
+ # Note that Mobility will try to use inner/outer joins appropriate to the query,
26
+ # so for example:
27
+ #
28
+ # Article.where(title: nil, content: nil) #=> OUTER JOIN (all nils)
29
+ # Article.where(title: "foo", content: nil) #=> INNER JOIN (one non-nil)
30
+ #
31
+ # In the first case, if we are in (say) the "en" locale, then we should match articles
32
+ # that have *no* article_translations with English locales (since no translation is
33
+ # equivalent to a nil value). If we used an inner join in the first case, an article
34
+ # with no English translations would be filtered out, so we use an outer join.
35
+ #
36
+ # However, if you call `where` multiple times, you may end up with an outer join
37
+ # when a (faster) inner join would have worked fine:
38
+ #
39
+ # Article.where(title: nil).where(content: "foo") #=> OUTER JOIN
40
+ #
41
+ # In this case, we are searching for a match on the article_translations table
42
+ # which has a NULL title and a content equal to "foo". Since we need a positive
43
+ # match for content, there must be an English translation on the article, thus
44
+ # we can use an inner join. However, Mobility will use an outer join since we don't
45
+ # want to modify the existing relation which has already been joined.
46
+ #
47
+ # To avoid this problem, simply make sure to either order your queries to place nil
48
+ # values last, or include all queried attributes in a single `where`:
49
+ #
50
+ # Article.where(title: nil, content: "foo") #=> INNER JOIN
51
+ #
52
+ define_method :where! do |opts, *rest|
53
+ if i18n_keys = attributes_extractor.call(opts)
54
+ opts = opts.with_indifferent_access
55
+ options = { outer_join: i18n_keys.all? { |attr| opts[attr].nil? } }
56
+ i18n_keys.each { |attr| opts["#{translation_class.table_name}.#{attr}"] = opts.delete(attr) }
57
+ super(opts, *rest).send("join_#{association_name}", options)
58
+ else
59
+ super(opts, *rest)
60
+ end
61
+ end
62
+
63
+ attributes.each do |attribute|
64
+ define_method :"find_by_#{attribute}" do |value|
65
+ find_by(attribute.to_sym => value)
66
+ end
67
+ end
68
+ end
69
+
70
+ def extended(relation)
71
+ super
72
+ association_name = @association_name
73
+ attributes_extractor = @attributes_extractor
74
+ translation_class = @translation_class
75
+
76
+ mod = Module.new do
77
+ define_method :not do |opts, *rest|
78
+ if i18n_keys = attributes_extractor.call(opts)
79
+ opts = opts.with_indifferent_access
80
+ i18n_keys.each { |attr| opts["#{translation_class.table_name}.#{attr}"] = opts.delete(attr) }
81
+ super(opts, *rest).send("join_#{association_name}")
82
+ else
83
+ super(opts, *rest)
84
+ end
85
+ end
86
+ end
87
+ relation.model.mobility_where_chain.prepend(mod)
88
+ end
89
+ end
90
+ end
91
+ end