minidoc 0.0.1
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 +17 -0
- data/.ruby-version +1 -0
- data/.travis.yml +8 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +22 -0
- data/README.md +76 -0
- data/Rakefile +11 -0
- data/lib/minidoc/associations.rb +60 -0
- data/lib/minidoc/autoload.rb +1 -0
- data/lib/minidoc/connection.rb +28 -0
- data/lib/minidoc/counters.rb +44 -0
- data/lib/minidoc/duplicate_key.rb +12 -0
- data/lib/minidoc/finders.rb +50 -0
- data/lib/minidoc/grid.rb +16 -0
- data/lib/minidoc/indexes.rb +31 -0
- data/lib/minidoc/read_only.rb +11 -0
- data/lib/minidoc/record_invalid.rb +9 -0
- data/lib/minidoc/test_helpers.rb +24 -0
- data/lib/minidoc/timestamps.rb +37 -0
- data/lib/minidoc/validations.rb +40 -0
- data/lib/minidoc/value.rb +21 -0
- data/lib/minidoc.rb +244 -0
- data/minidoc.gemspec +23 -0
- data/test/activemodel_test.rb +28 -0
- data/test/belongs_to_test.rb +49 -0
- data/test/connection_test.rb +20 -0
- data/test/counters_test.rb +48 -0
- data/test/duplicate_key_test.rb +21 -0
- data/test/grid_test.rb +33 -0
- data/test/helper.rb +22 -0
- data/test/indexes_test.rb +52 -0
- data/test/locale/en.yml +5 -0
- data/test/persistence_test.rb +183 -0
- data/test/query_test.rb +40 -0
- data/test/read_only_test.rb +34 -0
- data/test/timestamps_test.rb +21 -0
- data/test/uniqueness_validator_test.rb +68 -0
- data/test/validations_test.rb +36 -0
- metadata +196 -0
data/lib/minidoc.rb
ADDED
@@ -0,0 +1,244 @@
|
|
1
|
+
require "mongo"
|
2
|
+
require "virtus"
|
3
|
+
require "active_model"
|
4
|
+
require "active_support/core_ext"
|
5
|
+
|
6
|
+
class Minidoc
|
7
|
+
VERSION = "0.0.1"
|
8
|
+
|
9
|
+
require "minidoc/associations"
|
10
|
+
require "minidoc/connection"
|
11
|
+
require "minidoc/counters"
|
12
|
+
require "minidoc/finders"
|
13
|
+
require "minidoc/grid"
|
14
|
+
require "minidoc/indexes"
|
15
|
+
require "minidoc/read_only"
|
16
|
+
require "minidoc/record_invalid"
|
17
|
+
require "minidoc/duplicate_key"
|
18
|
+
require "minidoc/timestamps"
|
19
|
+
require "minidoc/validations"
|
20
|
+
require "minidoc/value"
|
21
|
+
|
22
|
+
include Connection
|
23
|
+
include Finders
|
24
|
+
include Validations
|
25
|
+
include Virtus.model
|
26
|
+
extend ActiveModel::Naming
|
27
|
+
include ActiveModel::Conversion
|
28
|
+
include ActiveModel::Validations
|
29
|
+
|
30
|
+
attribute :_id, BSON::ObjectId
|
31
|
+
alias_attribute :id, :_id
|
32
|
+
|
33
|
+
def self.delete_all
|
34
|
+
collection.remove({})
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.create(attrs = {})
|
38
|
+
new(attrs).tap(&:save)
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.create!(*args)
|
42
|
+
new(*args).tap(&:save!)
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.delete(id)
|
46
|
+
collection.remove(_id: BSON::ObjectId(id.to_s))
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.set(id, attributes)
|
50
|
+
id = BSON::ObjectId(id.to_s)
|
51
|
+
update_one(id, "$set" => attributes)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.unset(id, *keys)
|
55
|
+
id = BSON::ObjectId(id.to_s)
|
56
|
+
|
57
|
+
unsets = {}
|
58
|
+
keys.each do |key|
|
59
|
+
unsets[key] = 1
|
60
|
+
end
|
61
|
+
|
62
|
+
update_one(id, "$unset" => unsets)
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.update_one(id, updates)
|
66
|
+
collection.update({ "_id" => id }, updates)
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.atomic_set(query, attributes)
|
70
|
+
result = collection.update(query, "$set" => attributes)
|
71
|
+
result["ok"] == 1 && result["n"] == 1
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.value_class
|
75
|
+
@value_class ||= Class.new(self) do
|
76
|
+
attribute_set.each do |attr|
|
77
|
+
private "#{attr.name}="
|
78
|
+
end
|
79
|
+
|
80
|
+
private :attributes=
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# For databases that support it (e.g. TokuMX), perform the block within a
|
85
|
+
# transaction. For information on the +isolation+ argument, see
|
86
|
+
# https://www.percona.com/doc/percona-tokumx/commands.html#beginTransaction
|
87
|
+
def self.transaction(isolation = "mvcc")
|
88
|
+
return yield unless tokumx?
|
89
|
+
|
90
|
+
begin
|
91
|
+
database.command(beginTransaction: 1, isolation: isolation)
|
92
|
+
yield
|
93
|
+
rescue Exception => error
|
94
|
+
database.command(rollbackTransaction: 1) rescue nil
|
95
|
+
raise
|
96
|
+
ensure
|
97
|
+
begin
|
98
|
+
database.command(commitTransaction: 1) unless error
|
99
|
+
rescue Exception
|
100
|
+
database.command(rollbackTransaction: 1)
|
101
|
+
raise
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Rescue a duplicate key exception in the given block. Returns the result of
|
107
|
+
# the block, or +false+ if the exception was raised.
|
108
|
+
def self.rescue_duplicate_key_errors
|
109
|
+
yield
|
110
|
+
rescue Minidoc::DuplicateKey
|
111
|
+
false
|
112
|
+
rescue Mongo::OperationFailure => ex
|
113
|
+
if Minidoc::DuplicateKey.duplicate_key_exception(ex)
|
114
|
+
false
|
115
|
+
else
|
116
|
+
raise
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.tokumx?
|
121
|
+
@server_info ||= connection.server_info
|
122
|
+
@server_info.key?("tokumxVersion")
|
123
|
+
end
|
124
|
+
|
125
|
+
def initialize(attrs = {})
|
126
|
+
if attrs["_id"].nil? && attrs[:_id].nil?
|
127
|
+
attrs[:_id] = BSON::ObjectId.new
|
128
|
+
end
|
129
|
+
|
130
|
+
@new_record = true
|
131
|
+
@destroyed = false
|
132
|
+
|
133
|
+
super(attrs)
|
134
|
+
end
|
135
|
+
|
136
|
+
def ==(other)
|
137
|
+
other.is_a?(self.class) && self.id && self.id == other.id
|
138
|
+
end
|
139
|
+
|
140
|
+
def new_record?
|
141
|
+
@new_record
|
142
|
+
end
|
143
|
+
|
144
|
+
def destroyed?
|
145
|
+
@destroyed
|
146
|
+
end
|
147
|
+
|
148
|
+
def persisted?
|
149
|
+
!(new_record? || destroyed?)
|
150
|
+
end
|
151
|
+
|
152
|
+
def delete
|
153
|
+
self.class.delete(id)
|
154
|
+
end
|
155
|
+
|
156
|
+
def destroy
|
157
|
+
delete
|
158
|
+
@destroyed = true
|
159
|
+
end
|
160
|
+
|
161
|
+
def reload
|
162
|
+
new_object = self.class.find(self.id)
|
163
|
+
|
164
|
+
self.class.attribute_set.each do |attr|
|
165
|
+
self[attr.name] = new_object[attr.name]
|
166
|
+
end
|
167
|
+
|
168
|
+
self
|
169
|
+
end
|
170
|
+
|
171
|
+
def save
|
172
|
+
valid? ? create_or_update : false
|
173
|
+
end
|
174
|
+
|
175
|
+
def save!
|
176
|
+
valid? ? create_or_update : raise(RecordInvalid.new(self))
|
177
|
+
end
|
178
|
+
|
179
|
+
def set(attributes)
|
180
|
+
self.class.set(id, attributes)
|
181
|
+
|
182
|
+
attributes.each do |name, value|
|
183
|
+
self[name] = value
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def unset(*keys)
|
188
|
+
self.class.unset(id, *keys)
|
189
|
+
|
190
|
+
keys.each do |key|
|
191
|
+
self[key] = nil
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def atomic_set(query, attributes)
|
196
|
+
query[:_id] = id
|
197
|
+
|
198
|
+
if self.class.atomic_set(query, attributes)
|
199
|
+
attributes.each do |name, value|
|
200
|
+
self[name] = value
|
201
|
+
end
|
202
|
+
|
203
|
+
true
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def as_value
|
208
|
+
self.class.value_class.new(attributes)
|
209
|
+
end
|
210
|
+
|
211
|
+
def to_key
|
212
|
+
[id.to_s]
|
213
|
+
end
|
214
|
+
|
215
|
+
def to_param
|
216
|
+
id.to_s
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
def create_or_update
|
222
|
+
new_record? ? create : update
|
223
|
+
@new_record = false
|
224
|
+
true
|
225
|
+
rescue Mongo::OperationFailure => exception
|
226
|
+
if (duplicate_key_exception = Minidoc::DuplicateKey.duplicate_key_exception(exception))
|
227
|
+
raise duplicate_key_exception
|
228
|
+
else
|
229
|
+
raise
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def create
|
234
|
+
self.class.collection << attributes
|
235
|
+
end
|
236
|
+
|
237
|
+
def update
|
238
|
+
self.class.collection.update({ _id: id }, attributes)
|
239
|
+
end
|
240
|
+
|
241
|
+
if ActiveSupport.respond_to?(:run_load_hooks)
|
242
|
+
ActiveSupport.run_load_hooks(:mongo)
|
243
|
+
end
|
244
|
+
end
|
data/minidoc.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
Gem::Specification.new do |spec|
|
3
|
+
spec.name = "minidoc"
|
4
|
+
spec.version = "0.0.1"
|
5
|
+
spec.authors = ["Bryan Helmkamp"]
|
6
|
+
spec.email = ["bryan@brynary.com"]
|
7
|
+
spec.summary = %q{Lightweight wrapper for MongoDB documents}
|
8
|
+
spec.homepage = "https://github.com/brynary/minidoc"
|
9
|
+
spec.license = "MIT"
|
10
|
+
|
11
|
+
spec.files = `git ls-files`.split($/)
|
12
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
13
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
14
|
+
spec.require_paths = ["lib"]
|
15
|
+
|
16
|
+
spec.add_dependency "activesupport", ">= 3.0.0"
|
17
|
+
spec.add_dependency "activemodel", ">= 3.0.0"
|
18
|
+
spec.add_dependency "virtus", "~> 1.0.0"
|
19
|
+
spec.add_dependency "mongo", "~> 1"
|
20
|
+
spec.add_development_dependency "minitest"
|
21
|
+
spec.add_development_dependency "mocha"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class ActiveModelTest < Minidoc::TestCase
|
4
|
+
def test_model_name
|
5
|
+
assert_equal "User", User.model_name.to_s
|
6
|
+
end
|
7
|
+
|
8
|
+
def test_to_model
|
9
|
+
user = User.new
|
10
|
+
assert_equal user, user.to_model
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_to_key
|
14
|
+
user = User.new
|
15
|
+
user.id = BSON::ObjectId('52955618f9f6a52444000001')
|
16
|
+
assert_equal ["52955618f9f6a52444000001"], user.to_key
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_to_param
|
20
|
+
user = User.new
|
21
|
+
user.id = BSON::ObjectId('52955618f9f6a52444000001')
|
22
|
+
assert_equal "52955618f9f6a52444000001", user.to_param
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_to_path
|
26
|
+
assert_equal "users/user", User.new.to_partial_path
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class BelongsToTest < Minidoc::TestCase
|
4
|
+
class Cat < Minidoc
|
5
|
+
include Minidoc::Associations
|
6
|
+
belongs_to :owner, class_name: "User"
|
7
|
+
end
|
8
|
+
|
9
|
+
class User < ::User
|
10
|
+
end
|
11
|
+
|
12
|
+
def test_loading
|
13
|
+
assert_nil Cat.new.owner
|
14
|
+
user = User.create
|
15
|
+
cat = Cat.new(owner_id: user.id)
|
16
|
+
assert_equal user.id, cat.owner.id
|
17
|
+
cat.save
|
18
|
+
assert_equal user.id, cat.owner.id
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_caching
|
22
|
+
user = User.create(name: "Bryan")
|
23
|
+
cat = Cat.create(owner_id: user.id)
|
24
|
+
assert_equal "Bryan", cat.owner.name
|
25
|
+
user.set(name: "Noah")
|
26
|
+
assert_equal "Bryan", cat.owner.name # doesn't change
|
27
|
+
assert_equal "Noah", cat.reload.owner.name # changes
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_cache_expiry_on_field_update
|
31
|
+
user = User.create(name: "Bryan")
|
32
|
+
cat = Cat.create(owner_id: user.id)
|
33
|
+
assert_equal "Bryan", cat.owner.name
|
34
|
+
user.set(name: "Noah")
|
35
|
+
assert_equal "Bryan", cat.owner.name # doesn't change
|
36
|
+
cat.owner = user
|
37
|
+
assert_equal "Noah", cat.owner.name # changes
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_cache_expiry_on_id_update
|
41
|
+
user = User.create(name: "Bryan")
|
42
|
+
cat = Cat.create(owner_id: user.id)
|
43
|
+
assert_equal "Bryan", cat.owner.name
|
44
|
+
user.set(name: "Noah")
|
45
|
+
assert_equal "Bryan", cat.owner.name # doesn't change
|
46
|
+
cat.owner_id = user.id
|
47
|
+
assert_equal "Noah", cat.owner.name # changes
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class ConnectionTest < Minidoc::TestCase
|
4
|
+
class Company < Minidoc
|
5
|
+
self.collection_name = "accounts"
|
6
|
+
end
|
7
|
+
|
8
|
+
def test_collection_name
|
9
|
+
assert_equal "users", User.collection_name
|
10
|
+
assert_equal "accounts", Company.collection_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_collection
|
14
|
+
assert_equal "users", User.collection.name
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_database
|
18
|
+
assert_equal "minidoc_test", User.database.name
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class CountersTest < Minidoc::TestCase
|
4
|
+
class SimpleCounter < Minidoc
|
5
|
+
include Minidoc::Counters
|
6
|
+
|
7
|
+
counter :counter
|
8
|
+
end
|
9
|
+
|
10
|
+
class AdvancedCounter < Minidoc
|
11
|
+
include Minidoc::Counters
|
12
|
+
|
13
|
+
counter :counter, start: 2, step_size: 3
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_incrementing
|
17
|
+
x = SimpleCounter.create!
|
18
|
+
|
19
|
+
assert_equal 0, x.counter
|
20
|
+
assert_equal 1, x.increment_counter
|
21
|
+
assert_equal 2, x.increment_counter
|
22
|
+
assert_equal 3, x.increment_counter
|
23
|
+
assert_equal 3, x.reload.counter
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_options
|
27
|
+
x = AdvancedCounter.create!
|
28
|
+
|
29
|
+
assert_equal 2, x.counter
|
30
|
+
assert_equal 5, x.increment_counter
|
31
|
+
assert_equal 8, x.increment_counter
|
32
|
+
assert_equal 11, x.increment_counter
|
33
|
+
assert_equal 11, x.reload.counter
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_threading
|
37
|
+
x = SimpleCounter.create!
|
38
|
+
counters = []
|
39
|
+
|
40
|
+
[
|
41
|
+
Thread.new { 5.times { counters << x.increment_counter } },
|
42
|
+
Thread.new { 5.times { counters << x.increment_counter } },
|
43
|
+
Thread.new { 5.times { counters << x.increment_counter } },
|
44
|
+
].map(&:join)
|
45
|
+
|
46
|
+
assert_equal 15, counters.uniq.length
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class DuplicateKeyTest < Minidoc::TestCase
|
4
|
+
def test_rescue_duplicate_key_errors_result
|
5
|
+
user = User.create!
|
6
|
+
result = Minidoc.rescue_duplicate_key_errors do
|
7
|
+
User.create!(name: "two")
|
8
|
+
end
|
9
|
+
|
10
|
+
assert_equal "two", result.name
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_rescue_duplicate_key_errors_false
|
14
|
+
user = User.create!
|
15
|
+
result = Minidoc.rescue_duplicate_key_errors do
|
16
|
+
User.create!(_id: user.id, name: "two")
|
17
|
+
end
|
18
|
+
|
19
|
+
assert_equal false, result
|
20
|
+
end
|
21
|
+
end
|
data/test/grid_test.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class GridTest < Minidoc::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
super
|
7
|
+
@grid = Minidoc::Grid.new(Minidoc.database)
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_get_with_string
|
11
|
+
doc_id = @grid.put("estamos en espana")
|
12
|
+
as_string = doc_id.to_s
|
13
|
+
document = @grid.get(as_string)
|
14
|
+
assert_equal("estamos en espana", document.read)
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_get_json
|
18
|
+
hash = { "foo" => { "bar" => 1 } }
|
19
|
+
doc_id = @grid.put(hash.to_json)
|
20
|
+
as_string = doc_id.to_s
|
21
|
+
assert_equal(hash, @grid.get_json(as_string))
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_delete
|
25
|
+
doc_id = @grid.put("estamos en espana")
|
26
|
+
as_string = doc_id.to_s
|
27
|
+
@grid.delete(as_string)
|
28
|
+
assert_raises Mongo::GridFileNotFound do
|
29
|
+
@grid.get(doc_id)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'minidoc'
|
2
|
+
require 'minidoc/test_helpers'
|
3
|
+
require 'minitest/autorun'
|
4
|
+
require 'mocha/mini_test'
|
5
|
+
|
6
|
+
I18n.enforce_available_locales = false
|
7
|
+
I18n.load_path << File.expand_path("../locale/en.yml", __FILE__)
|
8
|
+
|
9
|
+
class User < Minidoc
|
10
|
+
attribute :name, String
|
11
|
+
attribute :age, Integer
|
12
|
+
end
|
13
|
+
|
14
|
+
$mongo = Mongo::MongoClient.from_uri(ENV["MONGODB_URI"] || "mongodb://localhost")
|
15
|
+
Minidoc.connection = $mongo
|
16
|
+
Minidoc.database_name = "minidoc_test"
|
17
|
+
|
18
|
+
class Minidoc::TestCase < Minitest::Test
|
19
|
+
def setup
|
20
|
+
Minidoc::TestHelpers.clear_database
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
class IndexesTest < Minidoc::TestCase
|
4
|
+
class Company < Minidoc
|
5
|
+
self.collection_name = "accounts"
|
6
|
+
|
7
|
+
include Minidoc::Indexes
|
8
|
+
|
9
|
+
attribute :name
|
10
|
+
attribute :title
|
11
|
+
attribute :description
|
12
|
+
end
|
13
|
+
|
14
|
+
def test_ensure_index_single
|
15
|
+
Company.ensure_index(:name)
|
16
|
+
|
17
|
+
assert_equal %w( name ), indexed_keys(Company, "name_1")
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_ensure_index_multiple
|
21
|
+
Company.ensure_index([:name, :title])
|
22
|
+
|
23
|
+
assert_equal %w( name title ), indexed_keys(Company, "name_1_title_1")
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_ensure_index_options
|
27
|
+
Company.ensure_index(:name, unique: true)
|
28
|
+
|
29
|
+
assert index_info(Company, "name_1")["unique"]
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_rescue_duplicate_key_errors_not_raising_exception
|
33
|
+
Company.ensure_index(:name, unique: true)
|
34
|
+
|
35
|
+
Company.rescue_duplicate_key_errors do
|
36
|
+
Company.create!(name: "CodeClimate")
|
37
|
+
Company.create!(name: "CodeClimate")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def index_info(klass, name)
|
44
|
+
klass.collection.index_information[name] || {}
|
45
|
+
end
|
46
|
+
|
47
|
+
def indexed_keys(klass, name)
|
48
|
+
key_info = index_info(klass, name)["key"] || {}
|
49
|
+
key_info.keys
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|