mova 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,73 @@
1
+ module Mova
2
+ module Interpolation
3
+ # Wrapper around {http://ruby-doc.org/core/Kernel.html#method-i-sprintf Kernel#sprintf} with
4
+ # fallback for missing placeholders.
5
+ #
6
+ # @since 0.1.0
7
+ class Sprintf
8
+ PLACEHOLDER_RE = Regexp.union(
9
+ /%%/, # escape character
10
+ /%\{(\w+)\}/, # %{hello}
11
+ /%<(\w+)>(.*?\d*\.?\d*[bBdiouxXeEfgGcps])/ # %<hello>.d
12
+ )
13
+ ESCAPE_SEQUENCE = "%%".freeze
14
+ ESCAPE_SEQUENCE_REPLACEMENT = "%".freeze
15
+
16
+ # Replaces each placeholder like "%{{hello}}" or "%<hello>3.0f" with given values.
17
+ # @return [String]
18
+ # @param string [String]
19
+ # @param values [Hash{Symbol => String}]
20
+ #
21
+ # @example
22
+ # interpolator.call("Hello, %{you}!", you: "world") #=> "Hello, world!"
23
+ # @example Sprintf-like formatting
24
+ # # this is the equivalent to `sprintf("%3.0f", 1.0)`
25
+ # interpolator.call("%<num>3.0f", num: 1.0) #=> " 1"
26
+ #
27
+ # @note Unlike `Kernel#sprintf` it won't raise an exception in case of missing
28
+ # placeholder. Instead {#missing_placeholder} will be used to return a default
29
+ # replacement.
30
+ # sprintf("Hello %{world}", other: "value") #=> KeyError: key{world} not found
31
+ # interpolator.call("Hello %{world}", other: "value") #=> "Hello %{world}"
32
+ #
33
+ # @see http://ruby-doc.org/core/Kernel.html#method-i-sprintf
34
+ def call(string, values)
35
+ string.to_str.gsub(PLACEHOLDER_RE) do |match|
36
+ if match == ESCAPE_SEQUENCE
37
+ ESCAPE_SEQUENCE_REPLACEMENT
38
+ else
39
+ placeholder = ($1 || $2).to_sym
40
+ replacement = values[placeholder] || missing_placeholder(placeholder, values, string)
41
+ $3 ? sprintf("%#{$3}", replacement) : replacement
42
+ end
43
+ end
44
+ end
45
+
46
+ module Overridable
47
+ # @return [String] default replacement for missing placeholder
48
+ # @param placeholder [Symbol]
49
+ # @param values [Hash{Symbol => String}] all given values for interpolation
50
+ #
51
+ # @example Wrap missing placeholders in HTML tag
52
+ # interpolator = Mova::Interpolation::Sprintf.new.tap do |i|
53
+ # def i.missing_placeholder(placeholder, values)
54
+ # "<span class='error'>#{placeholder}<span>"
55
+ # end
56
+ # end
57
+ # interpolator.call("%{my} %{notes}", my: "your") #=> "your <span class='error'>notes</span>"
58
+ #
59
+ # @example Raise an exception in case of missing placeholder
60
+ # interpolator = Mova::Interpolation::Sprintf.new.tap do |i|
61
+ # def i.missing_placeholder(placeholder, values)
62
+ # raise KeyError.new("#{placeholder.inspect} is missing, #{values.inspect} given")
63
+ # end
64
+ # end
65
+ # interpolator.call("%{my} %{notes}", my: "your") #=> KeyError: :notes is missing, {my: "your"} given
66
+ def missing_placeholder(placeholder, values)
67
+ "%{#{placeholder}}"
68
+ end
69
+ end
70
+ include Overridable
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,25 @@
1
+ module Mova
2
+ module ReadStrategy
3
+ # This strategy is more perfomant with a remote storage, where each
4
+ # read results in a network roundtrip. Even if your cache or database
5
+ # is located on localhost, reading from a socket is much more slower
6
+ # than reading from memory.
7
+ # Instead of making one read per locale/scope fallback, we get combination
8
+ # of all fallbacks and make one request to the storage.
9
+ #
10
+ # @example Instantiating a translator with eager strategy
11
+ # dalli = ActiveSupport::Cache::DalliStore.new("localhost:11211")
12
+ # translator = Mova::Translator.new(storage: dalli)
13
+ # translator.extend Mova::ReadStrategy::Eager
14
+ #
15
+ # @since 0.1.0
16
+ module Eager
17
+ def read_first(locales, key_with_scopes)
18
+ locales_with_scopes = Scope.cross_join(locales, key_with_scopes)
19
+ results = storage.read_multi(*locales_with_scopes)
20
+ _, value = results.find { |_, value| Mova.presence(value) }
21
+ value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ module Mova
2
+ module ReadStrategy
3
+ # This strategy is more perfomant with an in-memory storage, where
4
+ # read is cheap compared to a remote storage.
5
+ # It is included in {Translator} by default.
6
+ #
7
+ # @since 0.1.0
8
+ module Lazy
9
+ def read_first(locales, key_with_scopes)
10
+ locales.each do |locale|
11
+ key_with_scopes.each do |key|
12
+ result = storage.read(Scope.join(locale, key))
13
+ return result if Mova.presence(result)
14
+ end
15
+ end
16
+
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,94 @@
1
+ module Mova
2
+ # Translation keys are usually organized in a tree, where each nesting level
3
+ # corresponds to a specific part of your application. Such hierarchical organization
4
+ # allows to reuse the keys and keep their names relatively short.
5
+ #
6
+ # Full path to a key forms a scope. Think of a scope as a namespace.
7
+ #
8
+ # # here we have an example YAML file with "blank" keys within different scopes
9
+ # activemodel:
10
+ # errors:
11
+ # blank: Can't be blank
12
+ # message:
13
+ # blank: Please provide a message
14
+ #
15
+ # Since Mova is designed to work with any key-value storage, we need to store a key
16
+ # with its own full scope and a locale. We use dot-separated strings as it's a common
17
+ # format in Ruby community.
18
+ #
19
+ # "en.activemodel.errors.blank"
20
+ # "en.activemodel.errors.message.blank"
21
+ #
22
+ # @note In YAML (and other storages that map to a hash) you can't store a value
23
+ # for a nesting level itself.
24
+ # errors: !can't have translation here
25
+ # blank: Please enter a value
26
+ # Other key-value storages usually don't have such limitation, however, it's better
27
+ # to not introduce incompatibles in this area.
28
+ #
29
+ # @since 0.1.0
30
+ module Scope
31
+ extend self
32
+
33
+ SEPARATOR = ".".freeze
34
+
35
+ # Makes a new scope from given parts.
36
+ #
37
+ # @return [String]
38
+ # @param parts [Array<String, Symbol>, *Array<String, Symbol>]
39
+ #
40
+ # @example
41
+ # Scope.join("hello", "world") #=> "hello.world"
42
+ # Scope.join([:hello, "world"]) #=> "hello.world"
43
+ def join(*parts)
44
+ parts.join(SEPARATOR)
45
+ end
46
+
47
+ # Split a scope into parts.
48
+ #
49
+ # @return [Array<String>]
50
+ # @param scope [String]
51
+ #
52
+ # @example
53
+ # Scope.split("hello.world") #=> ["hello", "world"]
54
+ def split(scope)
55
+ scope.split(SEPARATOR)
56
+ end
57
+
58
+ # Recurrently flattens hash by converting its keys to fully scoped ones.
59
+ #
60
+ # @return [Hash{String => String}]
61
+ # @param translations [Hash{String/Symbol => String/Hash}] with multiple
62
+ # roots allowed
63
+ # @param current_scope for internal use
64
+ #
65
+ # @example
66
+ # Scope.flatten(en: {common: {hello: "hi"}}, de: {hello: "Hallo"}) #=>
67
+ # {"en.common.hello" => "hi", "de.hello" => "Hallo"}
68
+ def flatten(translations, current_scope = nil)
69
+ translations.each_with_object({}) do |(key, value), memo|
70
+ scope = current_scope ? join(current_scope, key) : key.to_s
71
+ if value.is_a?(Hash)
72
+ memo.merge!(flatten(value, scope))
73
+ else
74
+ memo[scope] = value
75
+ end
76
+ end
77
+ end
78
+
79
+ # Combines each locale with all keys.
80
+ #
81
+ # @return [Array<String>]
82
+ # @param locales [Array<String, Symbol>]
83
+ # @param keys [Array<String, Symbol>]
84
+ #
85
+ # @example
86
+ # Scope.cross_join([:de, :en], [:hello, :hi]) #=>
87
+ # ["de.hello", "de.hi", "en.hello", "en.hi"]
88
+ def cross_join(locales, keys)
89
+ locales.map do |locale|
90
+ keys.map { |key| join(locale, key) }
91
+ end.flatten
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,124 @@
1
+ module Mova
2
+ module Storage
3
+ # Allows to wrap several storages and treat them as one. All methods are called on
4
+ # each storage in order defined in the initializer.
5
+ #
6
+ # @since 0.1.0
7
+ class Chain
8
+ attr_reader :storages
9
+
10
+ def initialize(*storages)
11
+ @storages = storages
12
+
13
+ # Performance optimizations:
14
+ # * replace loop with OR operator in places where we know beforehand
15
+ # all iterated elements (storages)
16
+ # * avoid reading from the next storage if possible
17
+ instance_eval <<-EOM, __FILE__, __LINE__ + 1
18
+ def read(key)
19
+ #{
20
+ calls_to_each_storage = storages.map.each_with_index do |s, i|
21
+ "Mova.presence(storages[#{i}].read(key))"
22
+ end
23
+ calls_to_each_storage.join(" || ")
24
+ }
25
+ end
26
+
27
+ def read_multi(*keys)
28
+ #{
29
+ initialize_results = storages.map.each_with_index do |s, i|
30
+ "results#{i} = nil"
31
+ end
32
+ initialize_results.join("\n")
33
+ }
34
+ keys.each_with_object({}) do |key, memo|
35
+ result = \
36
+ #{
37
+ calls_to_each_storage = storages.map.each_with_index do |s, i|
38
+ "Mova.presence((results#{i} ||= storages[#{i}].read_multi(*keys))[key])"
39
+ end
40
+ calls_to_each_storage.join(" || ")
41
+ }
42
+ memo[key] = result if result
43
+ end
44
+ end
45
+ EOM
46
+ end
47
+
48
+ # @!method read(key)
49
+ # @return [String, nil] first non-empty value while trying each storage in order defined
50
+ # in the initializer.
51
+ # @param key [String]
52
+ #
53
+ # @example
54
+ # storage1.write("hello", "ruby")
55
+ # storage2.write("hello", "world")
56
+ # storage2.write("bye", "war")
57
+ # chain = Mova::Storage::Chain.new(storage1, storage2)
58
+ # chain.read("hello") #=> "ruby"
59
+ # chain.read("bye") #=> "war"
60
+ #
61
+ # @!parse
62
+ # def read(key)
63
+ # Mova.presence(storages[0].read(key)) || Mova.presence(storages[1].read(key)) || etc
64
+ # end
65
+
66
+ # @!method read_multi(*keys)
67
+ # @return [Hash{String => String}] composed result of all non-empty values. Hashes are merged
68
+ # backwards, so results from a first storage win over results from next one. However,
69
+ # non-empty value wins over empty one.
70
+ # @param keys [*Array<String>]
71
+ #
72
+ # @example
73
+ # storage1.write("hello", "ruby")
74
+ # storage1.write("empty", "")
75
+ # storage2.write("hello", "world")
76
+ # storage2.write("empty", "not so much")
77
+ # storage2.write("bye", "war")
78
+ # chain = Mova::Storage::Chain.new(storage1, storage2)
79
+ # chain.read_multi("hello", "bye", "empty") #=> {"hello" => "ruby", "bye" => "war", "empty" => "not so much"}
80
+ #
81
+ # @!parse
82
+ # def read_multi(*keys)
83
+ # results0 = nil
84
+ # results1 = nil
85
+ # etc
86
+ # keys.each_with_object({}) do |key, memo|
87
+ # result =
88
+ # Mova.presence((results0 ||= storages[0].read_multi(*keys))[key]) ||
89
+ # Mova.presence((results1 ||= storages[1].read_multi(*keys))[key]) || etc
90
+ # memo[key] = result if result
91
+ # end
92
+ # end
93
+
94
+ # @return [void]
95
+ # @param key [String]
96
+ # @param value [String, nil]
97
+ #
98
+ # @note Each storage will receive #write. Use {Readonly} if you wish to protect
99
+ # certain storages.
100
+ def write(key, value)
101
+ storages.each { |s| s.write(key, value) }
102
+ end
103
+
104
+ # @return [void]
105
+ #
106
+ # @note Each storage will receive #clear. Use {Readonly} if you wish to protect
107
+ # certain storages.
108
+ def clear
109
+ storages.each { |s| s.clear }
110
+ end
111
+
112
+ # @return [Boolean]
113
+ # @param key [String]
114
+ def exist?(key)
115
+ storages.any? { |s| s.exist?(key) }
116
+ end
117
+
118
+ # @private
119
+ def inspect
120
+ "<##{self.class.name} storages=#{storages.inspect}>"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,67 @@
1
+ module Mova
2
+ module Storage
3
+ # Thin wrapper around Hash.
4
+ #
5
+ # @note This class was designed to be *not* thread-safe for the sake of
6
+ # speed. However, if you store translations in static YAML files and
7
+ # do not write to the storage during application runtime, this is not a
8
+ # big deal.
9
+ #
10
+ # If you need thread-safe in-memory implemenation, use
11
+ # {http://api.rubyonrails.org/classes/ActiveSupport/Cache/MemoryStore.html ActiveSupport::Cache::MemoryStore}.
12
+ #
13
+ # Also note that with any in-memory implementation you'll have a copy of all
14
+ # translations data in each spawned worker. Depending on number of your locales,
15
+ # translation keys and workers, it may take up a lot of memory. This is the fastest
16
+ # storage though.
17
+ #
18
+ # @since 0.1.0
19
+ class Memory
20
+ def initialize
21
+ @storage = {}
22
+ end
23
+
24
+ # @return [String, nil]
25
+ # @param key [String]
26
+ def read(key)
27
+ @storage[key]
28
+ end
29
+
30
+ # @return [Hash]
31
+ # @param keys [*Array<String>]
32
+ # @example
33
+ # storage.write("foo", "bar")
34
+ # storage.write("baz", "qux")
35
+ # storage.read_multi("foo", "baz") #=> {"foo" => "bar", "baz" => "qux"}
36
+ def read_multi(*keys)
37
+ keys.each_with_object({}) do |key, memo|
38
+ result = read(key)
39
+ memo[key] = result if result
40
+ end
41
+ end
42
+
43
+ # @return [void]
44
+ # @param key [String]
45
+ # @param value [String, nil]
46
+ def write(key, value)
47
+ @storage[key] = value
48
+ end
49
+
50
+ # @return [Boolean]
51
+ # @param key [String]
52
+ def exist?(key)
53
+ @storage.key?(key)
54
+ end
55
+
56
+ # @return [void]
57
+ def clear
58
+ @storage.clear
59
+ end
60
+
61
+ # @private
62
+ def inspect
63
+ "<##{self.class.name}>"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,51 @@
1
+ module Mova
2
+ module Storage
3
+ # Wrapper around a storage that protects from writes.
4
+ #
5
+ # @since 0.1.0
6
+ class Readonly
7
+ attr_reader :storage
8
+
9
+ def initialize(storage)
10
+ @storage = storage
11
+ end
12
+
13
+ # @return [String, nil]
14
+ # @param key [String]
15
+ def read(key)
16
+ storage.read(key)
17
+ end
18
+
19
+ # @return [Hash{String => String}]
20
+ # @param keys [*Array<String>]
21
+ def read_multi(*keys)
22
+ storage.read_multi(*keys)
23
+ end
24
+
25
+ # @return [Boolean]
26
+ # @param key [String]
27
+ def exist?(key)
28
+ storage.exist?(key)
29
+ end
30
+
31
+ # @return [void]
32
+ # @param key [String]
33
+ # @param value [String, nil]
34
+ #
35
+ # @note Does nothing
36
+ def write(key, value)
37
+ end
38
+
39
+ # @return [void]
40
+ #
41
+ # @note Does nothing
42
+ def clear
43
+ end
44
+
45
+ # @private
46
+ def inspect
47
+ "<##{self.class.name} storage=#{storage.inspect}>"
48
+ end
49
+ end
50
+ end
51
+ end