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,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