mova 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.
@@ -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