active_cabinet 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/README.md +136 -0
- data/lib/active_cabinet.rb +167 -0
- data/lib/active_cabinet/config.rb +21 -0
- data/lib/active_cabinet/metaclass.rb +170 -0
- data/lib/active_cabinet/version.rb +3 -0
- metadata +62 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 774c19d270fc5767611ad0b42eaa06f7b98c3bae2bdfa2e4f8f0e382629daf59
|
4
|
+
data.tar.gz: 4d48778ba0cd2294b047d0292c87e963c4c16823a1effe6f93a207afeb182713
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9e9e84e8ae733967cee88489b45020c68609ef67172d5a0c8b9e3540d946d8a0a9d32e139362f95a3d0aa3f545c1a612c1993f3a4327f67559506d98c3ab8620
|
7
|
+
data.tar.gz: 3a9a02b9f0c0df27479db9ba4f6ae8c6e17cd3f10caad24fe00e669c7402f19b3dddc4461553d25e9b7cf61588b4bf668cf9b194bbf3cce27a319de7c2bb297f
|
data/README.md
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
ActiveCabinet
|
2
|
+
==================================================
|
3
|
+
|
4
|
+
---
|
5
|
+
|
6
|
+
ActiveCabinet is an ActiveRecord-inspired interface for HashCabinet, the
|
7
|
+
file-basd key-object store.
|
8
|
+
|
9
|
+
It allows you to create models that are stored in a file-based key-value
|
10
|
+
store, backed by Ruby's built in [SDBM].
|
11
|
+
|
12
|
+
---
|
13
|
+
|
14
|
+
Installation
|
15
|
+
--------------------------------------------------
|
16
|
+
|
17
|
+
$ gem install active_cabinet
|
18
|
+
|
19
|
+
|
20
|
+
|
21
|
+
Usage
|
22
|
+
--------------------------------------------------
|
23
|
+
|
24
|
+
Before trying these examples, create a directory named `db` - this is the
|
25
|
+
default directory where the cabinet files are stored.
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
require 'active_cabinet'
|
29
|
+
|
30
|
+
# Define a model
|
31
|
+
class Song < ActiveCabinet
|
32
|
+
end
|
33
|
+
|
34
|
+
# Create the model, and store it in the cabinet
|
35
|
+
# Each object must have at least an `id` and can have any number of
|
36
|
+
# attributes
|
37
|
+
song = Song.create id: 1, title: 'Moonchild', artist: 'Iron Maiden'
|
38
|
+
p song
|
39
|
+
#=> #<Song @attributes={:id=>1, :title=>"Moonchild", :artist=>"Iron Maiden"}>
|
40
|
+
|
41
|
+
# Get all records
|
42
|
+
Song.all #=> Array of Song objects
|
43
|
+
Song.count #=> 1
|
44
|
+
|
45
|
+
# Retrieve a specific record
|
46
|
+
Song[1]
|
47
|
+
Song.find 1
|
48
|
+
|
49
|
+
# Read one attribute or all attributes from a record
|
50
|
+
song.album
|
51
|
+
song.attributes
|
52
|
+
|
53
|
+
# Update a single attribute
|
54
|
+
song.year = 1988
|
55
|
+
song.save
|
56
|
+
|
57
|
+
# Update multiple attributes
|
58
|
+
song.update year: 1988, artist: 'Metallica'
|
59
|
+
song.update! year: 1988, artist: 'Metallica' # this variant also saves
|
60
|
+
```
|
61
|
+
|
62
|
+
### Restricting / allowing certain attributes
|
63
|
+
|
64
|
+
You may specify required arguments. Records without these attributes will
|
65
|
+
not be saved. Note that `id` is always required
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
class Song < ActiveCabinet
|
69
|
+
required_attributes :title, :artist
|
70
|
+
end
|
71
|
+
|
72
|
+
song = Song.new title: "Moonchild"
|
73
|
+
song.valid? # => false
|
74
|
+
song.error # => "missing required attributes: [:artist, :id]"
|
75
|
+
|
76
|
+
song = Song.new id: 1, title: "Moonchild", artist: 'Iron Maiden'
|
77
|
+
song.valid? # => true
|
78
|
+
|
79
|
+
# Additional attributes are still allowed
|
80
|
+
song.year = 1988
|
81
|
+
song.valid? # => true
|
82
|
+
```
|
83
|
+
|
84
|
+
You can also restrict the allowed optional attributes
|
85
|
+
|
86
|
+
```
|
87
|
+
class Song < ActiveCabinet
|
88
|
+
required_attributes :title
|
89
|
+
optional_attributes :artist
|
90
|
+
end
|
91
|
+
|
92
|
+
song = Song.new id: 1, title: 'Moonchild', album: 'Seventh Son of a Seventh Son'
|
93
|
+
song.valid? # => false
|
94
|
+
song.error # => "invalid attributes: [:album]"
|
95
|
+
```
|
96
|
+
|
97
|
+
In order to enforce only the required attributes, without optional ones, set
|
98
|
+
the value of `optional_attributes` to `false`
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
class Song < ActiveCabinet
|
102
|
+
required_attributes :title
|
103
|
+
optional_attributes false
|
104
|
+
end
|
105
|
+
|
106
|
+
song = Song.new id: 1, title: 'Moonchild', artist: 'Iron Maiden'
|
107
|
+
song.valid? # => false
|
108
|
+
song.error # => "invalid attributes: [:artist]"
|
109
|
+
```
|
110
|
+
|
111
|
+
### Configuring storage path
|
112
|
+
|
113
|
+
By default, `ActiveCabinet` stores all its files (two files per model) in the
|
114
|
+
`./db` directory. The file name is determined by the name of the class.
|
115
|
+
|
116
|
+
You can override both of these values
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
# Set the based directory for all cabinets
|
120
|
+
ActiveCabinet::Config.dir = "cabinets"
|
121
|
+
|
122
|
+
# Set the filename of your model
|
123
|
+
class Song < ActiveCabinet
|
124
|
+
cabinet_name "songs_collection"
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
For the full documentation, see the [Documentation on RubyDoc][docs]
|
129
|
+
|
130
|
+
|
131
|
+
[SDBM]: https://ruby-doc.org/stdlib-2.6.3/libdoc/sdbm/rdoc/SDBM.html
|
132
|
+
[docs]: https://rubydoc.info/gems/active_cabinet
|
133
|
+
|
134
|
+
---
|
135
|
+
|
136
|
+
[SDBM]: https://ruby-doc.org/stdlib-2.7.1/libdoc/sdbm/rdoc/SDBM.html
|
@@ -0,0 +1,167 @@
|
|
1
|
+
require 'hash_cabinet'
|
2
|
+
require 'active_cabinet/metaclass'
|
3
|
+
|
4
|
+
# @!attribute [r] attributes
|
5
|
+
# @return [Hash] the attributes of the record
|
6
|
+
# @!attribute [r] error
|
7
|
+
# @return [String, nil] the last validation error, after calling {valid?}
|
8
|
+
class ActiveCabinet
|
9
|
+
attr_reader :attributes, :error
|
10
|
+
|
11
|
+
# @!group Constructor
|
12
|
+
|
13
|
+
# Initializes a new record with {attributes}
|
14
|
+
#
|
15
|
+
# @param [Hash] attributes record attributes
|
16
|
+
def initialize(attributes = {})
|
17
|
+
@attributes = attributes.transform_keys(&:to_sym)
|
18
|
+
end
|
19
|
+
|
20
|
+
# @!group Attribute Management
|
21
|
+
|
22
|
+
# Returns an array containing {required_attributes} and {optional_attributes}.
|
23
|
+
#
|
24
|
+
# @return [Array<Symbol>] array of required attribute keys.
|
25
|
+
def allowed_attributes
|
26
|
+
self.class.allowed_attributes
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns an array of required record attributes.
|
30
|
+
#
|
31
|
+
# @see ActiveCabinet.required_attributes.
|
32
|
+
# @return [Array<Symbol>] the array of required attributes
|
33
|
+
def required_attributes
|
34
|
+
self.class.required_attributes
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns an array of optional record attributes.
|
38
|
+
#
|
39
|
+
# @see ActiveCabinet.optional_attributes.
|
40
|
+
# @return [Array<Symbol>] the array of optional attributes
|
41
|
+
def optional_attributes
|
42
|
+
self.class.optional_attributes
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns +true+ if the object is valid.
|
46
|
+
#
|
47
|
+
# @return [Boolean] +true+ if the record is valid.
|
48
|
+
def valid?
|
49
|
+
missing_keys = required_attributes - attributes.keys
|
50
|
+
if missing_keys.any?
|
51
|
+
@error = "missing required attributes: #{missing_keys}"
|
52
|
+
return false
|
53
|
+
end
|
54
|
+
|
55
|
+
if !optional_attributes or optional_attributes.any?
|
56
|
+
invalid_keys = attributes.keys - allowed_attributes
|
57
|
+
if invalid_keys.any?
|
58
|
+
@error = "invalid attributes: #{invalid_keys}"
|
59
|
+
return false
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
# @!group Dynamic Attribute Accessors
|
67
|
+
|
68
|
+
# Provides read/write access to {attributes}
|
69
|
+
def method_missing(method_name, *args, &blk)
|
70
|
+
name = method_name
|
71
|
+
return attributes[name] if attributes.has_key? name
|
72
|
+
|
73
|
+
suffix = nil
|
74
|
+
|
75
|
+
if name.to_s.end_with?('=', '?')
|
76
|
+
suffix = name[-1]
|
77
|
+
name = name[0..-2].to_sym
|
78
|
+
end
|
79
|
+
|
80
|
+
case suffix
|
81
|
+
when "="
|
82
|
+
attributes[name] = args.first
|
83
|
+
|
84
|
+
when "?"
|
85
|
+
!!attributes[name]
|
86
|
+
|
87
|
+
else
|
88
|
+
super
|
89
|
+
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Returns +true+ when calling +#respond_to?+ with an attribute name.
|
94
|
+
#
|
95
|
+
# @return [Boolean] +true+ if there is a matching attribute.
|
96
|
+
def respond_to_missing?(method_name, include_private = false)
|
97
|
+
name = method_name
|
98
|
+
name = name[0..-2].to_sym if name.to_s.end_with?('=', '?')
|
99
|
+
attributes.has_key?(name) || super
|
100
|
+
end
|
101
|
+
|
102
|
+
# @!group Loading and Saving
|
103
|
+
|
104
|
+
# Reads the attributes of the record from the cabinet and returns the
|
105
|
+
# record itself. If the record is not stored on disk, returns +nil+.
|
106
|
+
#
|
107
|
+
# @return [self, nil] the object or +nil+ if the object is not stored.
|
108
|
+
def reload
|
109
|
+
return nil unless saved?
|
110
|
+
update cabinet[id]
|
111
|
+
self
|
112
|
+
end
|
113
|
+
|
114
|
+
# Saves the record to the cabinet if it is valid. Returns the record on
|
115
|
+
# success, or +false+ on failure.
|
116
|
+
#
|
117
|
+
# @return [self, false] the record or +false+ on failure.
|
118
|
+
def save
|
119
|
+
if valid?
|
120
|
+
cabinet[id] = attributes
|
121
|
+
self
|
122
|
+
else
|
123
|
+
false
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Returns +true+ if the record exists in the cabinet.
|
128
|
+
#
|
129
|
+
# @note This method only verifies that the ID of the record exists. The
|
130
|
+
# attributes of the instance and the stored record may differ.
|
131
|
+
#
|
132
|
+
# @return [Boolean] +true+ if the record is saved in the cabinet.
|
133
|
+
def saved?
|
134
|
+
cabinet.key? id
|
135
|
+
end
|
136
|
+
|
137
|
+
# Update the record with new or modified attributes.
|
138
|
+
#
|
139
|
+
# @param [Hash] new_attributes record attributes
|
140
|
+
def update(new_attributes)
|
141
|
+
@attributes = attributes.merge new_attributes
|
142
|
+
end
|
143
|
+
|
144
|
+
# Update the record with new or modified attributes, and save.
|
145
|
+
#
|
146
|
+
# @param [Hash] new_attributes record attributes
|
147
|
+
def update!(new_attributes)
|
148
|
+
update new_attributes
|
149
|
+
save
|
150
|
+
end
|
151
|
+
|
152
|
+
# @!group Utilities
|
153
|
+
|
154
|
+
# Returns a Hash of attributes
|
155
|
+
#
|
156
|
+
# @return [Hash<Symbol, Object>] the hash of attriibutes/
|
157
|
+
def to_h
|
158
|
+
attributes
|
159
|
+
end
|
160
|
+
|
161
|
+
protected
|
162
|
+
|
163
|
+
def cabinet
|
164
|
+
self.class.cabinet
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class ActiveCabinet
|
2
|
+
# Configure the global behavior of {ActiveCabinet}.
|
3
|
+
#
|
4
|
+
# @example
|
5
|
+
# # Change the directory where cabinets are stored
|
6
|
+
# ActiveCabinet::Config.dir = "cabinets"
|
7
|
+
#
|
8
|
+
# @attr_writer [String] dir Sets the base directory for all cabinet files (default +'db'+).
|
9
|
+
class Config
|
10
|
+
class << self
|
11
|
+
attr_writer :dir
|
12
|
+
|
13
|
+
# Returns the base directory for all cabinet files.
|
14
|
+
#
|
15
|
+
# @return [String] the base directory for all cabinet files.
|
16
|
+
def dir
|
17
|
+
@dir ||= 'db'
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
require 'hash_cabinet'
|
2
|
+
require 'forwardable'
|
3
|
+
require 'active_cabinet/config'
|
4
|
+
|
5
|
+
# {ActiveCabinet} lets you create a +HashCabinet+ collection model by
|
6
|
+
# subclassing {ActiveCabinet}:
|
7
|
+
#
|
8
|
+
# class Song < ActiveCabinet
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# Now, you can perform CRUD operations on this collection, which will be
|
12
|
+
# persisted to disk:
|
13
|
+
#
|
14
|
+
# # Create
|
15
|
+
# Song.create id: 1, title: 'Moonchild', artist: 'Iron Maiden'
|
16
|
+
#
|
17
|
+
# # Read
|
18
|
+
# moonchild = Song[1] # or Song.find 1
|
19
|
+
#
|
20
|
+
# # Update
|
21
|
+
# moonchild.title = "22 Acacia Avenue"
|
22
|
+
# moonchild.save
|
23
|
+
# # or
|
24
|
+
# moonchild.update! title: "22 Acacia Avenue"
|
25
|
+
#
|
26
|
+
# # Delete
|
27
|
+
# Song.delete 1
|
28
|
+
#
|
29
|
+
class ActiveCabinet
|
30
|
+
class << self
|
31
|
+
extend Forwardable
|
32
|
+
def_delegators :cabinet, :count, :delete, :empty?, :keys, :size
|
33
|
+
|
34
|
+
# @!group Creating Records
|
35
|
+
|
36
|
+
# Creates and saves a new record instance.
|
37
|
+
#
|
38
|
+
# @param [String] id the record id.
|
39
|
+
# @param [Hash] attributes the attributes to create.
|
40
|
+
def []=(id, attributes)
|
41
|
+
create attributes.merge(id: id)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Creates and saves a new record instance.
|
45
|
+
#
|
46
|
+
# @param [Hash] attributes the attributes to create.
|
47
|
+
# @return [Object] the record.
|
48
|
+
def create(attributes)
|
49
|
+
record = new attributes
|
50
|
+
record.save || record
|
51
|
+
end
|
52
|
+
|
53
|
+
# @!group Reading Records
|
54
|
+
|
55
|
+
# Returns all records.
|
56
|
+
#
|
57
|
+
# @return [Array] array of all records.
|
58
|
+
def all
|
59
|
+
cabinet.values.map do |attributes|
|
60
|
+
new(attributes)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns all records, as an Array of Hashes.
|
65
|
+
#
|
66
|
+
# @return [Array] array of all records.
|
67
|
+
def all_attributes
|
68
|
+
cabinet.values
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns an array of records for which the block returns true.
|
72
|
+
#
|
73
|
+
# @yieldparam [Object] record all record instances.
|
74
|
+
def where
|
75
|
+
all.select { |record| yield record }
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns the record matching the +id+.
|
79
|
+
#
|
80
|
+
# @return [Object, nil] the object if found, or +nil+.
|
81
|
+
def find(id)
|
82
|
+
attributes = cabinet[id]
|
83
|
+
attributes ? new(attributes) : nil
|
84
|
+
end
|
85
|
+
alias [] find
|
86
|
+
|
87
|
+
# @!group Deleting Records
|
88
|
+
|
89
|
+
# Deletes a record matching the +id+
|
90
|
+
#
|
91
|
+
# @param [String] id the record ID.
|
92
|
+
# @return [Boolean] +true+ on success, +false+ otherwise.
|
93
|
+
def delete(id)
|
94
|
+
!!cabinet.delete(id)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Deletes all records.
|
98
|
+
def drop
|
99
|
+
cabinet.clear
|
100
|
+
end
|
101
|
+
|
102
|
+
# @!group Attribute Management
|
103
|
+
|
104
|
+
# Returns an array containing {required_attributes} and {optional_attributes}.
|
105
|
+
#
|
106
|
+
# @return [Array<Symbol>] array of required attribute keys.
|
107
|
+
def allowed_attributes
|
108
|
+
(optional_attributes || []) + required_attributes
|
109
|
+
end
|
110
|
+
|
111
|
+
# Sets the required record attribute names.
|
112
|
+
#
|
113
|
+
# @param [Array<Symbol>] *attributes one or more attribute names.
|
114
|
+
# @return [Array<Symbol>] the array of required attributes.
|
115
|
+
def required_attributes(*args)
|
116
|
+
args = args.first if args.first.is_a? Array
|
117
|
+
if args.any?
|
118
|
+
@required_attributes = args
|
119
|
+
@required_attributes.push :id unless @required_attributes.include? :id
|
120
|
+
@required_attributes
|
121
|
+
else
|
122
|
+
@required_attributes ||= [:id]
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Sets the optional record attribute names.
|
127
|
+
#
|
128
|
+
# @param [Array<Symbol>] *attributes one or more attribute names.
|
129
|
+
# @return [Array<Symbol>] the array of optional attributes.
|
130
|
+
def optional_attributes(*args)
|
131
|
+
args = args.first if args.first.is_a? Array
|
132
|
+
if args.first === false
|
133
|
+
@optional_attributes = false
|
134
|
+
elsif args.any?
|
135
|
+
@optional_attributes = *args
|
136
|
+
else
|
137
|
+
@optional_attributes.nil? ? [] : @optional_attributes
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# @!group Utilities
|
142
|
+
|
143
|
+
# Returns all records as a hash, with record IDs as the keys.
|
144
|
+
def to_h
|
145
|
+
cabinet.to_h.map { |id, attributes| [id, new(attributes)] }.to_h
|
146
|
+
end
|
147
|
+
|
148
|
+
# Returns the +HashCabinet+ instance.
|
149
|
+
#
|
150
|
+
# @return [HashCabinet] the +HashCabinet+ object.
|
151
|
+
def cabinet
|
152
|
+
@cabinet ||= HashCabinet.new "#{Config.dir}/#{cabinet_name}"
|
153
|
+
end
|
154
|
+
|
155
|
+
# Returns or sets the cabinet name.
|
156
|
+
# Defaults to the name of the class, lowercase.
|
157
|
+
#
|
158
|
+
# @param [String] name the name of the cabinet file.
|
159
|
+
# @return [String] name the name of the cabinet file.
|
160
|
+
def cabinet_name(new_name = nil)
|
161
|
+
if new_name
|
162
|
+
@cabinet = nil
|
163
|
+
@cabinet_name = new_name
|
164
|
+
else
|
165
|
+
@cabinet_name ||= self.to_s.downcase.gsub('::', '_')
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
end
|
170
|
+
end
|
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: active_cabinet
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Danny Ben Shitrit
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-09-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: hash_cabinet
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.1'
|
27
|
+
description: ActiveRecord-inspired interface for HashCabinet, the file-basd key-object
|
28
|
+
store.
|
29
|
+
email: db@dannyben.com
|
30
|
+
executables: []
|
31
|
+
extensions: []
|
32
|
+
extra_rdoc_files: []
|
33
|
+
files:
|
34
|
+
- README.md
|
35
|
+
- lib/active_cabinet.rb
|
36
|
+
- lib/active_cabinet/config.rb
|
37
|
+
- lib/active_cabinet/metaclass.rb
|
38
|
+
- lib/active_cabinet/version.rb
|
39
|
+
homepage: https://github.com/dannyben/active_cabinet
|
40
|
+
licenses:
|
41
|
+
- MIT
|
42
|
+
metadata: {}
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: 2.5.0
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
requirements: []
|
58
|
+
rubygems_version: 3.1.2
|
59
|
+
signing_key:
|
60
|
+
specification_version: 4
|
61
|
+
summary: Database-like interface for HashCabinet
|
62
|
+
test_files: []
|