dbmlite3 1.0.b1 → 2.0.0.pre.alpha.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,284 @@
1
+
2
+ require 'sequel'
3
+
4
+ require 'weakref'
5
+
6
+ module Lite3
7
+
8
+ # Wrapper around a Sequel::Database object.
9
+ #
10
+ # We do this instead of using them directly because transactions
11
+ # happen at the handle level rather than the file level and this
12
+ # lets us share the transaction across multiple tables in the same
13
+ # file.
14
+ #
15
+ # In addition, we can use this to transparently close and reopen the
16
+ # underlying database file when (e.g.) forking the process.
17
+ #
18
+ # Instances contain references to DBM objects using them. When the
19
+ # set becomes empty, the handle is closed; adding a reference will
20
+ # ensure the handle is open.
21
+ class Handle
22
+ attr_reader :path
23
+ def initialize(path)
24
+ @path = path
25
+ @db = open_db(path)
26
+ @refs = {}
27
+ end
28
+
29
+ private
30
+
31
+ def open_db(path)
32
+ return IS_JRUBY ?
33
+ Sequel.connect("jdbc:sqlite:#{path}") :
34
+ Sequel.sqlite(@path)
35
+ end
36
+
37
+ public
38
+
39
+ def to_s
40
+ "<#{self.class}:0x#{object_id.to_s(16)} path=#{@path}>"
41
+ end
42
+ alias inspect to_s
43
+
44
+
45
+ #
46
+ # References to the DBM object(s) using this handle.
47
+ #
48
+ # References are weak. scrub_refs! will remove all reclaimed refs
49
+ # and close the handle if there are none left. (Note that this
50
+ # doesn't preclude us from reopening the handle later, though. We
51
+ # could keep Handles around longer if we want and reuse them, but we
52
+ # don't.)
53
+ #
54
+
55
+ def addref(parent)
56
+ @refs[parent.object_id] = WeakRef.new(parent)
57
+ end
58
+
59
+ def delref(parent)
60
+ @refs.delete(parent.object_id)
61
+ scrub_refs!
62
+ end
63
+
64
+ def scrub_refs!
65
+ @refs.delete_if{|k,v| ! v.weakref_alive? }
66
+ disconnect! if @refs.empty?
67
+ end
68
+
69
+ def live_refs
70
+ scrub_refs!
71
+ return @refs.size
72
+ end
73
+
74
+
75
+ #
76
+ # Opening and closing
77
+ #
78
+
79
+ # Disconnect the underlying database handle.
80
+ def disconnect!
81
+ @db.disconnect
82
+ end
83
+
84
+
85
+ #
86
+ # Transactions
87
+ #
88
+
89
+ # Perform &block in a transaction. See DBM.transaction.
90
+ def transaction(&block)
91
+ @db.transaction({}, &block)
92
+ end
93
+
94
+ # Test if there is currently a transaction in progress
95
+ def transaction_active?
96
+ return @db.in_transaction?
97
+ end
98
+
99
+
100
+
101
+ #
102
+ # Table access; the common SQL idioms we care about. These all
103
+ # deal with tables of key/value pairs.
104
+ #
105
+
106
+ # Create a table of key-value pairs if it does not already exist.
107
+ def create_key_value_table(name)
108
+ @db.create_table?(name) do
109
+ String :key, primary_key: true
110
+ String :value
111
+ end
112
+ end
113
+
114
+ # Perform an upsert for the row with field 'key'
115
+ def upsert(table, key, value)
116
+ transaction {
117
+ recs = @db[table].where(key: key)
118
+ if recs.count == 0
119
+ @db[table].insert(key: key, value: value)
120
+ elsif recs.count == 1
121
+ recs.update(value: value)
122
+ else
123
+ raise InternalError.new("Duplicate entry for key '#{key}'")
124
+ end
125
+ }
126
+
127
+ return value
128
+ end
129
+
130
+ # Retrieve the 'value' field of the row with value 'key' in the given table.
131
+ def lookup(table, key)
132
+ row = @db[table].where(key:key).first
133
+ return nil unless row
134
+
135
+ return row[:value]
136
+ end
137
+
138
+ def clear_table(table)
139
+ @db[table].delete
140
+ end
141
+
142
+ def delete(table, key)
143
+ @db[table].where(key: key).delete
144
+ end
145
+
146
+ def get_size(table)
147
+ return @db[table].count
148
+ end
149
+
150
+
151
+ # Backend for `each`; evaluates `block` on each row in `table`
152
+ # with the undecoded key and value as arguments. It is *not* a
153
+ # single transaction.
154
+ #
155
+ # We do this instead of using `Dataset.each` because the latter is
156
+ # not guaranteed to be re-entrant.
157
+ #
158
+ # Each key/value pair is retrieved via a separate query so that it
159
+ # is safe to access the database from inside the block. Items are
160
+ # retrieved by rowid in increasing order. Since we preserve those,
161
+ # modifications done in the block (probably) won't break things.
162
+ #
163
+ # This is (probably) not very fast but it's (probably) good enough
164
+ # for most things.
165
+ def tbl_each(table, &block)
166
+ return if @db[table].count == 0
167
+
168
+ curr = -1
169
+ while true
170
+ row = @db[table].where{rowid > curr}
171
+ .limit(1)
172
+ .select(:rowid, :key, :value)
173
+ .first
174
+
175
+ return unless row
176
+ curr, key, value = *row.values
177
+
178
+ block.call(key, value)
179
+ end
180
+ end
181
+
182
+ # Wrapper around Dataset.each, with all the ensuing limitations.
183
+ def tbl_each_fast(table, &block)
184
+ @db[table].each(&block)
185
+ end
186
+
187
+
188
+ end
189
+
190
+
191
+
192
+ #
193
+ # Private classes
194
+ #
195
+
196
+ # Dummy `Handle` that throws an `Error` exception whenever something
197
+ # tries to treat it as an open handle. This replaces a `DBM`'s
198
+ # `Handle` object when `DBM.close` is called so that the error
199
+ # message will be useful if something tries to access a closed
200
+ # handle.
201
+ class ClosedHandle
202
+ def initialize(filename, table)
203
+ @filename, @table = [filename, table]
204
+ end
205
+
206
+ # We clone the rest of Handle's interface with methods that throw
207
+ # an Error.
208
+ Handle.instance_methods(false).each { |name|
209
+ next if method_defined? name
210
+ define_method(name) { |*args|
211
+ raise Error.new("Use of closed database at #{@filename}/#{@table}")
212
+ }
213
+ }
214
+ end
215
+
216
+
217
+ # Module to manage the collection of active Handle objects. See the
218
+ # docs for `Lite3::SQL` for an overview; this module hold the actual
219
+ # code and data.
220
+ module HandlePool
221
+ @@handles = {} # The hash of `Handle` objects keyed by filename
222
+
223
+ # Retrieve the `Handle` associated with `filename`, creating it
224
+ # first if necessary. `filename` is normalized with
225
+ # `File.realpath` before using as a key and so is as good or bad
226
+ # as that for detecting an existing file.
227
+ def self.get(filename)
228
+
229
+ # Scrub @@handles of all inactive Handles
230
+ self.gc
231
+
232
+ # We need to convert the filename to a canonical
233
+ # form. `File.realpath` does this for us but only if the file
234
+ # exists. If not, we use it on the parent directory instead and
235
+ # use `File.join` to create the full path.
236
+ if File.exist?(filename)
237
+ File.file?(filename) or
238
+ raise Error.new("Filename '#{filename}' exists but is not a file.")
239
+
240
+ filename = File.realpath(filename)
241
+ else
242
+ dn = File.dirname(filename)
243
+ File.directory?(dn) or
244
+ raise Error.new("Parent directory '#{dn}' nonexistant or " +
245
+ "not a directory.")
246
+
247
+ filename = File.join(File.realpath(dn), File.basename(filename))
248
+ end
249
+
250
+ @@handles[filename] = Handle.new(filename) unless
251
+ @@handles.has_key?(filename)
252
+
253
+ return @@handles[filename]
254
+ end
255
+
256
+ # Close all underlying database connections.
257
+ def self.close_all
258
+ Sequel::DATABASES.each(&:disconnect)
259
+ end
260
+
261
+ # Close and remove all Handle objects with no refs and return a
262
+ # hash mapping the filename for each live Handle to the number of
263
+ # DBM objects that currently reference it. Does **NOT** perform a
264
+ # Ruby GC.
265
+ def self.gc
266
+ results = {}
267
+ @@handles.select!{|path, handle|
268
+ handle.scrub_refs!
269
+
270
+ if handle.live_refs == 0
271
+ @@handles.delete(path)
272
+ next false
273
+ end
274
+
275
+ results[path] = handle.live_refs
276
+ true
277
+ }
278
+
279
+ return results
280
+ end
281
+ end
282
+
283
+ private_constant :Handle, :ClosedHandle, :HandlePool
284
+ end
@@ -0,0 +1,87 @@
1
+
2
+ module Lite3
3
+
4
+ # This module provides some basic access to the underlying
5
+ # `Sequel::Database` objects used by `Lite3::DBM` to actually store
6
+ # and retrieve data.
7
+ #
8
+ # The only thing you need to care about is that if your process
9
+ # forks, you *must* invoke `Lite3::SQL.close_all` before forking the
10
+ # process. Otherwise, it will clone the connection and could lead
11
+ # to database corruption.
12
+ #
13
+ # More details:
14
+ #
15
+ # `Lite3` maintains a pool of private handle objects (private class
16
+ # `Lite3::Handle`) which in turn manage the `Sequel::Database`
17
+ # objects that actually do the work. There is one handle per
18
+ # SQLite3 database file; since each `DBM` represents one table in a
19
+ # SQLite3 file, multiple `DBM` objects will use the same handle.
20
+ #
21
+ # Handle objects can themselves close and replace their
22
+ # `Sequel::Database` objects transparently.
23
+ #
24
+ # The underlying system keeps track of which `DBM` objects reference
25
+ # which files and will close a file's `Sequel::Database` when all
26
+ # of the `DBM`s using it have been closed. (It does **not** handle
27
+ # the case where a `DBM` object remains open and goes out of scope;
28
+ # that object will be kept around for the life of the process.)
29
+ #
30
+ # Mostly, you don't need to care about this. However, it affects
31
+ # you in two ways:
32
+ #
33
+ # 1. Transactions are done at the file level and not the table level.
34
+ # This means that you can access separate tables in the same
35
+ # transaction, which is a Very Good Thing.
36
+ #
37
+ # 2. You can safely fork the current process and keep using existing
38
+ # `DBM` objects in both processes, provided you've invoked
39
+ # `close_all` before the fork. This will have closed the actual
40
+ # database handles (which can't tolerate being carried across a
41
+ # fork) and opens new ones the next time they're needed.
42
+ #
43
+ # If you find yourself needing to be sure that you don't have any
44
+ # unexpected open file handles (e.g. before forking or if you need
45
+ # Windows to unlock it), you should call `close_all`.
46
+ #
47
+ # Otherwise, it's safe to ignore this stuff.
48
+
49
+
50
+ # This module provides some basic, consistent access to the
51
+ # underlying database library(es) (currently `sequel`).
52
+ module SQL
53
+
54
+ # Tests if the underlying database libraries are threadsafe.
55
+ #
56
+ # (Currently, it always returns true, since Sequel does that for
57
+ # us.)
58
+ def self.threadsafe?
59
+ return true
60
+ end
61
+
62
+ # Disconnect and delete all database handles and associated
63
+ # metadata that are no longer needed (i.e. because their
64
+ # corresponding `DBM`s have been closed or reclaimed).
65
+ #
66
+ # Returns a hash mapping the path to each open database file to
67
+ # the number of live DBM objects referencing it.
68
+ #
69
+ # You normally won't need to explicitly call this, but it's
70
+ # useful for testing and debugging.
71
+ def self.gc() return HandlePool.gc; end
72
+
73
+ # Close and remove the underlying database connections. This does
74
+ # not invalidate existing `Lite3::DBM` objects; they will recreate
75
+ # the connections when needed.
76
+ #
77
+ # The main use for this is for safely forking the current process.
78
+ # You should call this just before each `fork` to avoid potential
79
+ # corruption from duplicated database handles.
80
+ #
81
+ # This **should not** be called while a database operation is in
82
+ # progress. (E.g. do **not** call this from the block of
83
+ # `DBM.each`.)
84
+ def self.close_all() return HandlePool.close_all end
85
+ end
86
+
87
+ end
@@ -212,9 +212,11 @@ Serializations = Set.new
212
212
  it "implements each_*" do
213
213
  db = newdb.call(Tmp.file, "floop")
214
214
 
215
+ # Empty DBMs don't evaluate their bloc
215
216
  count = 0
216
217
  db.each {|k,v| count += 1}
217
218
  db.each_pair {|k,v| count += 1}
219
+ expect( count ) .to eq 0
218
220
 
219
221
 
220
222
  db["foo"] = 42
@@ -236,6 +238,64 @@ Serializations = Set.new
236
238
  db.close
237
239
  end
238
240
 
241
+ it "allows database modification in `each`" do
242
+ db = newdb.call(Tmp.file, "floop")
243
+
244
+ db["foo"] = 42
245
+ db["bar"] = 99
246
+ db["quux"] = 123
247
+ db["baz"] = 999
248
+
249
+ count = 0
250
+ db.each do|key, value|
251
+ count += 1
252
+
253
+ case count
254
+ when 1
255
+ expect(key) .to eq "foo"
256
+ expect(value) .to eq 42
257
+ db['foo'] = 'new_foo'
258
+
259
+ when 2
260
+ expect(key) .to eq "bar"
261
+ expect(value) .to eq 99
262
+
263
+ db['baz'] = "new_baz"
264
+
265
+ expect(db['foo']) .to eq "new_foo"
266
+
267
+ db.delete("quux")
268
+
269
+ when 3
270
+ expect(key) .to eq "baz"
271
+ expect(value) .to eq "new_baz"
272
+
273
+ when 4
274
+ fail "there should not be 4 items!"
275
+ end
276
+ end
277
+
278
+ expect(count) .to eq 3
279
+
280
+ db.close
281
+ end
282
+
283
+ it "implements fast_each" do
284
+ db = newdb.call(Tmp.file, "floop")
285
+
286
+ db["foo"] = 42
287
+ db["bar"] = 99
288
+ db["quux"] = 123
289
+
290
+ expected = []
291
+ db.fast_each{|key, value| expected.push [key, value]}
292
+
293
+ expect(expected) .to eq [ ["foo", 42], ["bar", 99], ["quux", 123]]
294
+
295
+ db.close
296
+ end
297
+
298
+
239
299
  it "deletes items from the table" do
240
300
  db = newdb.call(Tmp.file, "floop")
241
301
 
@@ -792,7 +852,7 @@ describe Lite3::DBM do
792
852
  end
793
853
 
794
854
  it "keeps most of its names private" do
795
- expect( Lite3.constants.to_set ) .to eq %i{SQL DBM Error}.to_set
855
+ expect( Lite3.constants.to_set ) .to eq %i{SQL DBM Error InternalError}.to_set
796
856
  end
797
857
  end
798
858
 
@@ -816,15 +876,6 @@ describe Lite3::SQL do
816
876
  db
817
877
  }
818
878
 
819
- # it "manages a pool of DB handles that should now all be closed." do
820
- # # If this fails, it (probably) means the previous tests didn't
821
- # # clean up after themselves.
822
- # GC.start
823
- # expect( Lite3::SQL.gc.empty? ) .to be true
824
-
825
- # Lite3::SQL.close_all # smoketest
826
- # end
827
-
828
879
  it "lets you close the actual handle without impeding database use" do
829
880
  expect( Lite3::SQL.gc.size ) .to eq 0
830
881
 
@@ -840,72 +891,19 @@ describe Lite3::SQL do
840
891
 
841
892
  # Referencing DBM objects should be db1 and db2
842
893
  path, refs = stats.to_a[0]
894
+ expect( path ) .to eq file
895
+ expect( refs ) .to eq 2
843
896
 
844
- expect( refs.size ) .to eq 2
845
- expect( refs.include?(db1) ) .to be true
846
- expect( refs.include?(db2) ) .to be true
847
-
848
- # Underlying handles should be open
849
- expect( db1.handle_closed? ) .to be false
850
- expect( db2.handle_closed? ) .to be false
897
+ # We can no longer test if the underlying file handles are still
898
+ # open, so we don't.
851
899
 
852
- # Test closing it
900
+ # Test closing it and forcing a re-open
853
901
  Lite3::SQL.close_all
854
- expect( db1.handle_closed? ) .to be true
855
- expect( db2.handle_closed? ) .to be true
856
-
857
- # Test auto-opening them.
858
902
  expect( db1["foo"] ) .to eq vv["foo"]
859
- expect( db1.handle_closed? ) .to be false
860
- expect( db2.handle_closed? ) .to be false
861
903
 
862
- db1.close
863
- db2.close
864
-
865
- expect( Lite3::SQL.gc.keys.size ) .to eq 0
866
- end
867
-
868
- # it "(eventually) closes handles that have gone out of scope" do
869
- # expect( Lite3::SQL.gc.keys.size ) .to eq 0
870
-
871
- # file = Tmp.file
872
- # db1 = newbasic.call(file, "first")
873
-
874
- # expect( db1.handle_closed? ) .to be false
875
- # expect( Lite3::SQL.gc.keys.size ) .to eq 1
876
-
877
- # db1 = nil
878
- # GC.start
879
- # expect( Lite3::SQL.gc.keys.size ) .to eq 0
880
- # end
881
-
882
- it "does close_all with multiple files" do
883
- db1 = newbasic.call(Tmp.file, "first")
884
- db2 = newbasic.call(Tmp.file, "second")
885
-
886
- # The above should be using the same handle, which is currently
887
- # open.
888
-
889
- stats = Lite3::SQL.gc
890
- expect( stats.keys.size ) .to eq 2
891
-
892
- all_refs = stats.values.flatten
893
- expect( all_refs.include?(db1) ) .to be true
894
- expect( all_refs.include?(db2) ) .to be true
895
-
896
- # Underlying handles should be open
897
- expect( db1.handle_closed? ) .to be false
898
- expect( db2.handle_closed? ) .to be false
899
-
900
- # Test closing it
901
- Lite3::SQL.close_all
902
- expect( db1.handle_closed? ) .to be true
903
- expect( db2.handle_closed? ) .to be true
904
-
905
- # Test auto-opening them.
904
+ # Repeat, but this time use the underlying lib
905
+ Sequel::DATABASES.each(&:disconnect)
906
906
  expect( db1["foo"] ) .to eq vv["foo"]
907
- expect( db1.handle_closed? ) .to be false
908
- expect( db2.handle_closed? ) .to be true
909
907
 
910
908
  db1.close
911
909
  db2.close
@@ -913,8 +911,7 @@ describe Lite3::SQL do
913
911
  expect( Lite3::SQL.gc.keys.size ) .to eq 0
914
912
  end
915
913
 
916
-
917
- it "allows multipe table accesses in the same transaction" do
914
+ it "allows multiple table accesses in the same transaction" do
918
915
  file = Tmp.file
919
916
  db1 = newbasic.call(file, "first")
920
917
  db2 = Lite3::DBM.new(file, "second")
@@ -964,6 +961,50 @@ describe Lite3::SQL do
964
961
  expect{ db1.size } .to raise_error Lite3::Error
965
962
  expect{ db1.to_a } .to raise_error Lite3::Error
966
963
  end
964
+
965
+ it "finalizes DBM objects that have gone out of scope." do
966
+
967
+ # This is really difficult to test because there's no reliable way
968
+ # to get the garbage collector to clean up when we want it to. As
969
+ # such, we make the attempt and skip with a warning if db2 hasn't
970
+ # been reclaimed.
971
+ #
972
+ # (Dropping into the debugger after GC.start seems to help.)
973
+
974
+
975
+ file = Tmp.file
976
+ db1 = newbasic.call(file, "first")
977
+ db2 = Lite3::DBM.new(file, "second")
978
+
979
+ # Two DBMs are currently open.
980
+ stats = Lite3::SQL.gc
981
+ expect( stats.size ) .to be 1
982
+ expect( stats.values[0] ) .to be 2
983
+
984
+ # Make db2 a weak reference so it goes out of scope after a GC.
985
+ # It's possible that a GC redesign will this.
986
+ db2 = WeakRef.new(db2)
987
+ GC.start
988
+
989
+ # Uncommenting the next line and then resuming seems to work:
990
+ #byebug
991
+
992
+ if db2.weakref_alive?
993
+ db2.close
994
+ db1.close
995
+ skip "GC has't reclaimed the handle; bailing."
996
+ end
997
+
998
+ # There should now be exactly 1 open DBM
999
+ stats = Lite3::SQL.gc
1000
+ expect( stats.size ) .to be 1
1001
+ expect( stats.values[0] ) .to be 1
1002
+
1003
+ db1.close
1004
+ end
1005
+
1006
+
1007
+
967
1008
  end
968
1009
 
969
1010
 
metadata CHANGED
@@ -1,29 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dbmlite3
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.b1
4
+ version: 2.0.0.pre.alpha.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Reuter
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2022-02-21 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.65.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 5.65.0
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: sqlite3
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - "~>"
18
32
  - !ruby/object:Gem::Version
19
- version: '1.4'
33
+ version: 1.6.1
20
34
  type: :runtime
21
35
  prerelease: false
22
36
  version_requirements: !ruby/object:Gem::Requirement
23
37
  requirements:
24
38
  - - "~>"
25
39
  - !ruby/object:Gem::Version
26
- version: '1.4'
40
+ version: 1.6.1
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rspec
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -83,32 +97,19 @@ files:
83
97
  - README.md
84
98
  - Rakefile
85
99
  - dbmlite3.gemspec
86
- - doc/Lite3.html
87
- - doc/Lite3/DBM.html
88
- - doc/Lite3/Error.html
89
- - doc/Lite3/SQL.html
90
- - doc/_index.html
91
- - doc/class_list.html
92
- - doc/css/common.css
93
- - doc/css/full_list.css
94
- - doc/css/style.css
95
- - doc/file.README.html
96
- - doc/file_list.html
97
- - doc/frames.html
98
- - doc/index.html
99
- - doc/js/app.js
100
- - doc/js/full_list.js
101
- - doc/js/jquery.js
102
- - doc/method_list.html
103
- - doc/top-level-namespace.html
100
+ - extras/benchmark.rb
104
101
  - lib/dbmlite3.rb
102
+ - lib/internal_lite3/dbm.rb
103
+ - lib/internal_lite3/error.rb
104
+ - lib/internal_lite3/handle.rb
105
+ - lib/internal_lite3/sql.rb
105
106
  - spec/dbmlite3_spec.rb
106
107
  - spec/spec_helper.rb
107
108
  homepage: https://codeberg.org/suetanvil/dbmlite3
108
109
  licenses:
109
110
  - MIT
110
111
  metadata: {}
111
- post_install_message:
112
+ post_install_message:
112
113
  rdoc_options: []
113
114
  require_paths:
114
115
  - lib
@@ -116,16 +117,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
116
117
  requirements:
117
118
  - - ">="
118
119
  - !ruby/object:Gem::Version
119
- version: 2.2.0
120
+ version: 2.7.0
120
121
  required_rubygems_version: !ruby/object:Gem::Requirement
121
122
  requirements:
122
123
  - - ">"
123
124
  - !ruby/object:Gem::Version
124
125
  version: 1.3.1
125
126
  requirements:
126
- - sqlite3 gem, Ruby MRI (required by sqlite3)
127
- rubygems_version: 3.1.2
128
- signing_key:
127
+ - Sequel, sqlite3, Ruby MRI
128
+ rubygems_version: 3.2.15
129
+ signing_key:
129
130
  specification_version: 4
130
131
  summary: A DBM-style key-value store using SQLite3
131
132
  test_files: []