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