mobility 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,48 @@
1
+ module Mobility
2
+ module Backend
3
+ class Sequel::KeyValue::QueryMethods < Sequel::QueryMethods
4
+ def initialize(attributes, **options)
5
+ super
6
+ attributes_extractor = @attributes_extractor
7
+ association_name, translations_class = options[:association_name], options[:class_name]
8
+
9
+ define_method :"join_#{association_name}" do |*attributes, **options|
10
+ attributes.inject(self) do |relation, attribute|
11
+ join_type = options[:outer_join] ? :left_outer : :inner
12
+ relation.join_table(join_type,
13
+ translations_class.table_name,
14
+ {
15
+ key: attribute.to_s,
16
+ locale: Mobility.locale.to_s,
17
+ translatable_type: model.name,
18
+ translatable_id: ::Sequel[:"#{model.table_name}"][:id]
19
+ },
20
+ table_alias: "#{attribute}_#{association_name}")
21
+ end
22
+ end
23
+
24
+ # TODO: find a better way to do this that doesn't involve overriding
25
+ # a private method...
26
+ define_method :_filter_or_exclude do |invert, clause, *cond, &block|
27
+ if i18n_keys = attributes_extractor.call(cond.first)
28
+ cond = cond.first.dup
29
+ i18n_nulls = i18n_keys.select { |key| cond[key].nil? }
30
+ i18n_keys.each { |attr| cond[::Sequel[:"#{attr}_#{association_name}"][:value]] = cond.delete(attr) }
31
+ super(invert, clause, cond, &block).
32
+ send("join_#{association_name}", *(i18n_keys - i18n_nulls)).
33
+ send("join_#{association_name}", *i18n_nulls, outer_join: true)
34
+ else
35
+ super(invert, clause, *cond, &block)
36
+ end
37
+ end
38
+ private :_filter_or_exclude
39
+
40
+ attributes.each do |attribute|
41
+ define_method :"first_by_#{attribute}" do |value|
42
+ where(attribute => value).select_all(model.table_name).first
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ module Mobility
2
+ module Backend
3
+ module Sequel
4
+ =begin
5
+
6
+ Defines query method overrides to handle translated attributes for Sequel
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.map! &:to_sym
15
+ @attributes_extractor = lambda do |cond|
16
+ cond.is_a?(Hash) && (cond.keys & attributes).presence
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,133 @@
1
+ module Mobility
2
+ module Backend
3
+ =begin
4
+
5
+ Implements {Mobility::Backend::Serialized} backend for Sequel models, using the
6
+ Sequel serialization plugin.
7
+
8
+ @see http://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/Serialization.html Sequel serialization plugin
9
+ @see http://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/SerializationModificationDetection.html Sequel serialization_modification_detection plugin
10
+
11
+ @example Define attribute with serialized backend
12
+ class Post < Sequel::Model
13
+ include Mobility
14
+ translates :title, backend: :serialized, format: :yaml
15
+ end
16
+
17
+ @example Read and write attribute translations
18
+ post = Post.create(title: "foo")
19
+ post.title
20
+ #=> "foo"
21
+ Mobility.locale = :ja
22
+ post.title = "あああ"
23
+ post.save
24
+ post.deserialized_values[:title] # get deserialized value
25
+ #=> {:en=>"foo", :ja=>"あああ"}
26
+ post.title_before_mobility # get serialized value
27
+ #=> "---\n:en: foo\n:ja: \"あああ\"\n"
28
+
29
+ =end
30
+ class Sequel::Serialized
31
+ include Backend
32
+
33
+ autoload :QueryMethods, 'mobility/backend/sequel/serialized/query_methods'
34
+
35
+ # @!group Backend Accessors
36
+ #
37
+ # @!macro backend_reader
38
+ def read(locale, **options)
39
+ translations[locale]
40
+ end
41
+
42
+ # @!macro backend_reader
43
+ def write(locale, value, **options)
44
+ translations[locale] = value
45
+ end
46
+ # @!endgroup
47
+
48
+ # @!group Backend Configuration
49
+ # @option options [Symbol] format (:yaml) Serialization format
50
+ # @raise [ArgumentError] if a format other than +:yaml+ or +:json+ is passed in
51
+ def self.configure!(options)
52
+ options[:format] ||= :yaml
53
+ options[:format] = options[:format].downcase.to_sym
54
+ raise ArgumentError, "Serialized backend only supports yaml or json formats." unless [:yaml, :json].include?(options[:format])
55
+ end
56
+ # @!endgroup
57
+
58
+ setup do |attributes, options|
59
+ format = options[:format]
60
+ plugin :serialization
61
+ plugin :serialization_modification_detection
62
+
63
+ attributes.each do |_attribute|
64
+ attribute = _attribute.to_sym
65
+ self.serialization_map[attribute] = Serialized.serializer_for(format)
66
+ self.deserialization_map[attribute] = Serialized.deserializer_for(format)
67
+ end
68
+
69
+ method_overrides = Module.new do
70
+ define_method :initialize_set do |values|
71
+ attributes.each { |attribute| send(:"#{attribute}_before_mobility=", {}.send(:"to_#{format}")) }
72
+ super(values)
73
+ end
74
+ end
75
+ include method_overrides
76
+
77
+ extension = Module.new do
78
+ define_method :i18n do
79
+ @mobility_scope ||= super().with_extend(QueryMethods.new(attributes, options))
80
+ end
81
+ end
82
+ extend extension
83
+
84
+ include SerializationModificationDetectionFix
85
+ end
86
+
87
+ # Returns deserialized column value
88
+ # @return [Hash]
89
+ def translations
90
+ _attribute = attribute.to_sym
91
+ if model.deserialized_values.has_key?(_attribute)
92
+ model.deserialized_values[_attribute]
93
+ elsif model.frozen?
94
+ deserialize_value(_attribute, serialized_value)
95
+ else
96
+ model.deserialized_values[_attribute] = deserialize_value(_attribute, serialized_value)
97
+ end
98
+ end
99
+
100
+ # @!group Cache Methods
101
+ # @return [Hash]
102
+ def new_cache
103
+ translations
104
+ end
105
+
106
+ # @return [Boolean]
107
+ def write_to_cache?
108
+ true
109
+ end
110
+ # @!endgroup
111
+
112
+ # @note The original serialization_modification_detection plugin sets
113
+ # +@original_deserialized_values+ to be +@deserialized_values+, which
114
+ # doesn't work. Setting it to a new empty hash seems to work better.
115
+ module SerializationModificationDetectionFix
116
+ def after_save
117
+ super()
118
+ @original_deserialized_values = {}
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def deserialize_value(column, value)
125
+ model.send(:deserialize_value, column, value)
126
+ end
127
+
128
+ def serialized_value
129
+ model.send("#{attribute}_before_mobility")
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,20 @@
1
+ module Mobility
2
+ module Backend
3
+ class Sequel::Serialized::QueryMethods < Sequel::QueryMethods
4
+ def initialize(attributes, **options)
5
+ super
6
+ attributes_extractor = @attributes_extractor
7
+ cond_checker = @cond_checker = lambda do |cond|
8
+ if i18n_keys = attributes_extractor.call(cond)
9
+ raise ArgumentError,
10
+ "You cannot query on mobility attributes translated with the Serialized backend (#{i18n_keys.join(", ")})."
11
+ end
12
+ end
13
+
14
+ define_method :where do |*cond, &block|
15
+ cond_checker.call(cond.first) || super(*cond, &block)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,149 @@
1
+ module Mobility
2
+ module Backend
3
+ =begin
4
+
5
+ Implements the {Mobility::Backend::Table} backend for Sequel models.
6
+
7
+ =end
8
+ class Sequel::Table
9
+ include Backend
10
+
11
+ autoload :QueryMethods, 'mobility/backend/sequel/table/query_methods'
12
+
13
+ # @return [Symbol] name of the association method
14
+ attr_reader :association_name
15
+
16
+ # @!macro backend_constructor
17
+ # @option options [Symbol] association_name Name of association
18
+ def initialize(model, attribute, **options)
19
+ super
20
+ @association_name = options[:association_name]
21
+ end
22
+
23
+ # @!group Backend Accessors
24
+ # @!macro backend_reader
25
+ def read(locale, **options)
26
+ translation_for(locale).send(attribute)
27
+ end
28
+
29
+ # @!macro backend_reader
30
+ def write(locale, value, **options)
31
+ translation_for(locale).tap { |t| t.send("#{attribute}=", value) }.send(attribute)
32
+ end
33
+
34
+ # @!group Backend Configuration
35
+ # @option options [Symbol] association_name (:mobility_model_translations) Name of association method
36
+ # @option options [Symbol] table_name Name of translation table
37
+ # @option options [Symbol] foreign_key Name of foreign key
38
+ # @option options [Symbol] subclass_name Name of subclass to append to model class to generate translation class
39
+ # @raise [CacheRequired] if cache option is false
40
+ def self.configure!(options)
41
+ raise CacheRequired, "Cache required for Sequel::Table backend" if options[:cache] == false
42
+ table_name = options[:model_class].table_name
43
+ options[:table_name] ||= :"#{table_name.to_s.singularize}_translations"
44
+ options[:foreign_key] ||= table_name.to_s.downcase.singularize.camelize.foreign_key
45
+ if (association_name = options[:association_name]).present?
46
+ options[:subclass_name] ||= association_name.to_s.singularize.camelize
47
+ else
48
+ options[:association_name] = :mobility_model_translations
49
+ options[:subclass_name] ||= :Translation
50
+ end
51
+ %i[table_name foreign_key association_name subclass_name].each { |key| options[key] = options[key].to_sym }
52
+ end
53
+ # @!endgroup
54
+
55
+ setup do |attributes, options|
56
+ association_name = options[:association_name]
57
+ subclass_name = options[:subclass_name]
58
+
59
+ cache_accessor_name = :"__#{association_name}_cache"
60
+
61
+ attr_accessor cache_accessor_name
62
+
63
+ translation_class =
64
+ if self.const_defined?(subclass_name, false)
65
+ const_get(subclass_name, false)
66
+ else
67
+ const_set(subclass_name, Class.new(::Sequel::Model(options[:table_name]))).tap do |klass|
68
+ klass.include ::Mobility::Sequel::ModelTranslation
69
+ end
70
+ end
71
+
72
+ one_to_many association_name,
73
+ class: translation_class.name,
74
+ key: options[:foreign_key],
75
+ reciprocal: :translated_model
76
+
77
+ translation_class.many_to_one :translated_model,
78
+ class: name,
79
+ key: options[:foreign_key],
80
+ reciprocal: association_name
81
+
82
+ plugin :association_dependencies, association_name => :destroy
83
+
84
+ callback_methods = Module.new do
85
+ define_method :after_save do
86
+ super()
87
+ send(cache_accessor_name).each_value do |translation|
88
+ translation.id ? translation.save : send("add_#{association_name.to_s.singularize}", translation)
89
+ end if send(cache_accessor_name)
90
+ end
91
+ end
92
+ include callback_methods
93
+
94
+ extension = Module.new do
95
+ define_method :i18n do
96
+ @mobility_scope ||= super().with_extend(QueryMethods.new(attributes, options))
97
+ end
98
+ end
99
+ extend extension
100
+
101
+ include Mobility::Sequel::ColumnChanges.new(attributes)
102
+ end
103
+
104
+ # @!group Cache Methods
105
+ # @return [Table::TranslationsCache]
106
+ def new_cache
107
+ reset_model_cache unless model_cache
108
+ model_cache.for(attribute)
109
+ end
110
+
111
+ # @return [Boolean]
112
+ def write_to_cache?
113
+ true
114
+ end
115
+
116
+ def clear_cache
117
+ model_cache.clear if model_cache
118
+ end
119
+ # @!endgroup
120
+
121
+ private
122
+
123
+ def translation_for(locale)
124
+ translation = translations.find { |t| t.locale == locale.to_s }
125
+ translation ||= translation_class.new(locale: locale)
126
+ translation
127
+ end
128
+
129
+ def translations
130
+ model.send(association_name)
131
+ end
132
+
133
+ def translation_class
134
+ @translation_class ||= options[:model_class].const_get(options[:subclass_name])
135
+ end
136
+
137
+ def model_cache
138
+ model.send(:"__#{association_name}_cache")
139
+ end
140
+
141
+ def reset_model_cache
142
+ model.send(:"__#{association_name}_cache=",
143
+ Table::TranslationsCache.new { |locale| translation_for(locale) })
144
+ end
145
+
146
+ class CacheRequired < ::StandardError; end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,48 @@
1
+ module Mobility
2
+ module Backend
3
+ class Sequel::Table::QueryMethods < Sequel::QueryMethods
4
+ def initialize(attributes, **options)
5
+ super
6
+ association_name = options[:association_name]
7
+ @association_name = association_name
8
+ foreign_key = options[:foreign_key]
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
+ join_type = options[:outer_join] ? :left_outer : :inner
18
+ join_table(join_type,
19
+ translation_class.table_name,
20
+ {
21
+ locale: Mobility.locale.to_s,
22
+ foreign_key => ::Sequel[model.table_name][:id]
23
+ })
24
+ end
25
+
26
+ # See note in AR Table QueryMethods class about limitations of
27
+ # query methods on translated attributes when searching on nil values.
28
+ #
29
+ define_method :_filter_or_exclude do |invert, clause, *cond, &block|
30
+ if i18n_keys = attributes_extractor.call(cond.first)
31
+ cond = cond.first.dup
32
+ outer_join = i18n_keys.all? { |key| cond[key].nil? }
33
+ i18n_keys.each { |attr| cond[::Sequel[translation_class.table_name][attr]] = cond.delete(attr) }
34
+ super(invert, clause, cond, &block).send("join_#{association_name}", outer_join: outer_join)
35
+ else
36
+ super(invert, clause, *cond, &block)
37
+ end
38
+ end
39
+
40
+ attributes.each do |attribute|
41
+ define_method :"first_by_#{attribute}" do |value|
42
+ where(attribute => value).select_all(model.table_name).first
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ module Mobility
2
+ module Backend
3
+ =begin
4
+
5
+ Stores translations as serialized attributes in a single text column. This
6
+ implies that the translated values are not searchable, and thus this backend is
7
+ not recommended unless specific constraints prevent use of other solutions.
8
+
9
+ To use this backend, ensure that the model table has a text column on its table
10
+ with the same name as the translated attribute.
11
+
12
+ ==Backend Options
13
+
14
+ ===+format+
15
+
16
+ Format for serialization. Either +:yaml+ (default) or +:json+.
17
+
18
+ @see Mobility::Backend::ActiveRecord::Serialized
19
+ @see Mobility::Backend::Sequel::Serialized
20
+
21
+ =end
22
+ module Serialized
23
+ include OrmDelegator
24
+
25
+ class << self
26
+ def serializer_for(format)
27
+ lambda do |obj|
28
+ return if obj.nil?
29
+ if obj.is_a? Hash
30
+ obj = obj.inject({}) do |translations, (locale, value)|
31
+ translations[locale] = value.to_s if value.present?
32
+ translations
33
+ end
34
+ else
35
+ raise ArgumentError, "Attribute is supposed to be a Hash, but was a #{obj.class}. -- #{obj.inspect}"
36
+ end
37
+
38
+ obj.send("to_#{format}")
39
+ end
40
+ end
41
+
42
+ def deserializer_for(format)
43
+ case format
44
+ when :yaml
45
+ lambda { |v| YAML.load(v) }
46
+ when :json
47
+ lambda { |v| JSON.parse(v, symbolize_names: true) }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end