mova 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.yardopts +1 -0
- data/CONTRIBUTING.md +47 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +22 -0
- data/README.md +102 -0
- data/Rakefile +7 -0
- data/benchmarks/compare/locale_fallbacks.rb +42 -0
- data/benchmarks/compare/long_scope.rb +48 -0
- data/benchmarks/each_vs_or.rb +42 -0
- data/benchmarks/fallbacks_with_local_storage.rb +21 -0
- data/benchmarks/fallbacks_with_remote_storage.rb +24 -0
- data/lib/mova.rb +36 -0
- data/lib/mova/interpolation/sprintf.rb +73 -0
- data/lib/mova/read_strategy/eager.rb +25 -0
- data/lib/mova/read_strategy/lazy.rb +21 -0
- data/lib/mova/scope.rb +94 -0
- data/lib/mova/storage/chain.rb +124 -0
- data/lib/mova/storage/memory.rb +67 -0
- data/lib/mova/storage/readonly.rb +51 -0
- data/lib/mova/translator.rb +183 -0
- data/mova.gemspec +18 -0
- data/test/acceptance_test.rb +22 -0
- data/test/interpolation/sprintf_test.rb +60 -0
- data/test/mova_test.rb +21 -0
- data/test/read_strategy/eager_test.rb +47 -0
- data/test/read_strategy/lazy_test.rb +46 -0
- data/test/scope_test.rb +41 -0
- data/test/storage/chain_test.rb +100 -0
- data/test/storage/memory_test.rb +52 -0
- data/test/storage/readonly_test.rb +36 -0
- data/test/test_helper.rb +47 -0
- data/test/translator/get_test.rb +55 -0
- data/test/translator/initialize_test.rb +23 -0
- data/test/translator/put_test.rb +27 -0
- metadata +121 -0
@@ -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
|
data/lib/mova/scope.rb
ADDED
@@ -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
|