db-struct 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.
@@ -0,0 +1,110 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>
7
+ Top Level Namespace
8
+
9
+ &mdash; Documentation by YARD 0.9.37
10
+
11
+ </title>
12
+
13
+ <link rel="stylesheet" href="css/style.css" type="text/css" />
14
+
15
+ <link rel="stylesheet" href="css/common.css" type="text/css" />
16
+
17
+ <script type="text/javascript">
18
+ pathId = "";
19
+ relpath = '';
20
+ </script>
21
+
22
+
23
+ <script type="text/javascript" charset="utf-8" src="js/jquery.js"></script>
24
+
25
+ <script type="text/javascript" charset="utf-8" src="js/app.js"></script>
26
+
27
+
28
+ </head>
29
+ <body>
30
+ <div class="nav_wrap">
31
+ <iframe id="nav" src="class_list.html?1"></iframe>
32
+ <div id="resizer"></div>
33
+ </div>
34
+
35
+ <div id="main" tabindex="-1">
36
+ <div id="header">
37
+ <div id="menu">
38
+
39
+ <a href="_index.html">Index</a> &raquo;
40
+
41
+
42
+ <span class="title">Top Level Namespace</span>
43
+
44
+ </div>
45
+
46
+ <div id="search">
47
+
48
+ <a class="full_list_link" id="class_list_link"
49
+ href="class_list.html">
50
+
51
+ <svg width="24" height="24">
52
+ <rect x="0" y="4" width="24" height="4" rx="1" ry="1"></rect>
53
+ <rect x="0" y="12" width="24" height="4" rx="1" ry="1"></rect>
54
+ <rect x="0" y="20" width="24" height="4" rx="1" ry="1"></rect>
55
+ </svg>
56
+ </a>
57
+
58
+ </div>
59
+ <div class="clear"></div>
60
+ </div>
61
+
62
+ <div id="content"><h1>Top Level Namespace
63
+
64
+
65
+
66
+ </h1>
67
+ <div class="box_info">
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+ </div>
80
+
81
+ <h2>Defined Under Namespace</h2>
82
+ <p class="children">
83
+
84
+
85
+
86
+
87
+ <strong class="classes">Classes:</strong> <span class='object_link'><a href="DBStruct.html" title="DBStruct (class)">DBStruct</a></span>
88
+
89
+
90
+ </p>
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+ </div>
101
+
102
+ <div id="footer">
103
+ Generated on Wed Sep 11 13:00:26 2024 by
104
+ <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
105
+ 0.9.37 (ruby-3.1.4).
106
+ </div>
107
+
108
+ </div>
109
+ </body>
110
+ </html>
data/gem.deps.rb ADDED
@@ -0,0 +1,7 @@
1
+
2
+ # Instruct `gem install -n` to fetch dependencies from the gemspec
3
+ # file.
4
+
5
+ source 'https://rubygems.org'
6
+ gemspec
7
+
data/lib/dbstruct.rb ADDED
@@ -0,0 +1,29 @@
1
+
2
+ require 'set'
3
+
4
+ require 'sequel'
5
+
6
+ # `DBStruct` presents a (SQLite3) database table in a way that
7
+ # closely mimics a Ruby `Hash` of `Struct` objects.
8
+ #
9
+ # This is an abstract base class. To use it, you create a subclass
10
+ # using the `with` method and define the fields to be used. This also
11
+ # associates the new class with a `Sequel` dataset.
12
+ #
13
+ # Subinstances behave similarly to Ruby's `Struct` class. However,
14
+ # setting or reading a value accesses the corresponding database row.
15
+ #
16
+ # Each object also knows its database row ID, an integer that can be
17
+ # used to look up this object. If a subinstance outlives its database
18
+ # row, attempting to access the fields will raise a
19
+ # {DBStruct::MissingRowError}. You can use methods {.present?} or
20
+ # {.deleted?} to test if this has happened.
21
+ class DBStruct
22
+ class BogoHash; end
23
+ end
24
+
25
+ require 'internal/error'
26
+ require 'internal/util'
27
+ require 'internal/dbstruct_base'
28
+ require 'internal/dbstruct_class'
29
+ require 'internal/bogohash'
@@ -0,0 +1,226 @@
1
+
2
+ # `BogoHash` provides a `Hash`-like interface to the database table
3
+ # corresponding to a {DBStruct} subclass.
4
+ #
5
+ # You can think if it as a `Hash` that holds all instances of a
6
+ # particular {DBStruct} subclass keyed on the objects' `rowid`
7
+ # attributes. Most of the `Hash` interface is available (including
8
+ # `Enumerable`) with the exception of `[]=`. `delete` and `delete_if`
9
+ # work as expected, however.
10
+ #
11
+ # You should never explicitly create a `BogoHash`. Instead, use the
12
+ # class's {DBStruct.items} or {DBStruct.where} methods.
13
+ #
14
+ # Since these methods can be used to narrow the range of items, a
15
+ # `BogoHash` does not necessarily encompass all instances of its
16
+ # associated `DBStruct` subclass. Rows that do not match the
17
+ # selection criteria are not available even when explicitly addressed
18
+ # by row ID.
19
+ #
20
+ # Under the hood, a `BogoHash` is a wrapper around a
21
+ # `Sequel::Dataset`.
22
+ class DBStruct::BogoHash
23
+ include Enumerable
24
+
25
+ # @!visibility private
26
+ # Internal use only!
27
+ def initialize(dbs_klass, dataset, query)
28
+ @dbs_klass = dbs_klass
29
+ @dataset = dataset
30
+ @query = query.dup.freeze # used for printing only
31
+ end
32
+
33
+ # Return a human-friendly string describing this object.
34
+ #
35
+ # @return [String]
36
+ def inspect
37
+ params = ""
38
+ params = "(#{@query.map(&:inspect).join(", ")})" unless @query.empty?
39
+ return "#{@dbs_klass}.items#{params}"
40
+ end
41
+ alias to_s inspect
42
+
43
+ # Retrieve the item at `rowid` or nil if its not present.
44
+ def [](rowid)
45
+ row = @dataset.first(_id: rowid)
46
+ return nil unless row
47
+ return @dbs_klass.new(_id: rowid)
48
+ end
49
+
50
+ # Test if `rowid` is one of the keys in `self`.
51
+ def has_key?(rowid) = !! self[rowid]
52
+ alias include? has_key?
53
+ alias member? has_key?
54
+ alias key? has_key?
55
+
56
+ # Return the number of items in `self`.
57
+ def size = @dataset.count
58
+ alias length size
59
+
60
+ # Test if self has no items
61
+ def empty? = size() == 0
62
+
63
+ # Return all keys.
64
+ # @return [Array[Integer]] - the row IDs
65
+ def keys = map{|k,v| k}
66
+
67
+ # Return all values.
68
+ # @return [Array[DBStruct.with(...)]]
69
+ def values = map{|k,v| v}
70
+
71
+ # Return all keys and values as an array of two-element arrays.
72
+ # @return [Array[Array[Integer, DBStruct.with(...)]]]
73
+ def to_a = map{|k,v| [k,v]}
74
+
75
+ #
76
+ # Deletion
77
+ #
78
+
79
+
80
+ # Delete `rowid` and its associated value from the underlying table
81
+ # (and `self` by extension). If `rowid` is not present, does
82
+ # nothing.
83
+ #
84
+ # Note that the deleted row object will be returned but can no
85
+ # longer be used. In particular, it does not hold the contents of
86
+ # the deleted row.
87
+ #
88
+ # @return [DBStruct.with(...)] The deleted value or nil
89
+ def delete(rowid, &block)
90
+ transaction {
91
+ unless has_key?(rowid)
92
+ return block.value if block
93
+ return nil
94
+ end
95
+
96
+ val = self[rowid]
97
+ @dataset.where(_id: rowid).delete
98
+ return val
99
+ }
100
+ end
101
+
102
+ # Evaluate the block on each key-value pair in `self` end delete
103
+ # each entry for which the block returns true.
104
+ #
105
+ # Like all enumeration operations, the tests and deletions occur in
106
+ # a single transaction.
107
+ #
108
+ # @yield [value] The block to evaluate
109
+ def delete_if(&block)
110
+ transaction {
111
+ self.each{ |k, v| block.call(k,v) and delete(k) }
112
+ }
113
+ return self
114
+ end
115
+ alias reject! delete_if
116
+
117
+ # Delete all entries
118
+ def clear
119
+ @dataset.delete
120
+ return self
121
+ end
122
+
123
+
124
+ #
125
+ # Enumeration
126
+ #
127
+
128
+
129
+ # Evaluate `block` over each key-and-value pair (i.e. row and
130
+ # row-id). If no block is given, returns an Enumerator instead.
131
+ #
132
+ # It is safe to perform other database operations inside the block
133
+ # or enumeration loop. This includes modifications to the database
134
+ # being enumerated, although whether or not added or removed items
135
+ # are still visited is undefined.
136
+ #
137
+ # If called with a block, the entire operation happens within a
138
+ # single transaction.
139
+ #
140
+ # Since this class imports Enumerable, all of its functionality is
141
+ # also available.
142
+ def each(&block) = each_backend(:basic_each, block)
143
+ alias each_pair each
144
+
145
+ # Like {.each} but only yields the key (i.e. row ID).
146
+ def each_key(&block) = each_backend(:basic_each_key, block)
147
+
148
+ # Like {.each} but only yields the value.
149
+ def each_value(&block) = each_backend(:basic_each_value, block)
150
+
151
+ private
152
+
153
+ def row_id_after(prev_rowid)
154
+ row = @dataset
155
+ .where{_id > prev_rowid}
156
+ .limit(1)
157
+ .select(:_id)
158
+ .first
159
+ return row[:_id] if row
160
+ return nil
161
+ end
162
+
163
+ # This is slow, but will usually work even if there are database
164
+ # operations in the block.
165
+ def basic_each(&block)
166
+ return if size == 0
167
+
168
+ curr = -1
169
+ while true
170
+ rowid = row_id_after(curr)
171
+ return unless rowid
172
+
173
+ block.call(rowid, self[rowid])
174
+ curr = rowid
175
+ end
176
+ end
177
+
178
+ def basic_each_key(&block) = basic_each{|k,v| block.call(k)}
179
+ def basic_each_value(&block) = basic_each{|k,v| block.call(v)}
180
+
181
+ # Handle the enumeration methods (each, each_key, each_value) as
182
+ # expected: if given, call with the block inside a transaction;
183
+ # otherwise, return an Enumerator for it.
184
+ def each_backend(each_method, block)
185
+ return self.to_enum(each_method) unless block
186
+ transaction { self.send(each_method, &block) }
187
+ return self
188
+ end
189
+
190
+
191
+ # TODO: unsafe_each via Dataset.each; faster but troublesome if you
192
+ # modify things within the block.
193
+
194
+ public
195
+
196
+ #
197
+ # Advanced Querying
198
+ #
199
+
200
+ # Expose `Sequel::Dataset#where` for more advanced querying.
201
+ #
202
+ # The underlying `Sequel::Dataset`'s `where` method is called with
203
+ # all of the arguments and the result is a new BogoHash containing
204
+ # only the items that match the query.
205
+ #
206
+ # This is the Bigger Hammer you can use to narrow a down to a subset
207
+ # of your data when groups are not enough.
208
+ #
209
+ # @return [BogoHash]
210
+ def where(*cond, **kwcond, &block)
211
+ new_dataset = @dataset.where(*cond, **kwcond, &block)
212
+ desc = ["SQL:#{new_dataset.sql}"]
213
+ return self.class.new(@dbs_klass, new_dataset, desc)
214
+ end
215
+
216
+ #
217
+ # Helpers
218
+ #
219
+
220
+ # Calls the underling `Sequel::Database#transaction` methods with
221
+ # all arguments.
222
+ #
223
+ # Convenience method.
224
+ def transaction(*args, **kwargs, &block) =
225
+ @dbs_klass.transaction(*args, **kwargs, &block)
226
+ end
@@ -0,0 +1,144 @@
1
+
2
+
3
+ class DBStruct
4
+ # Returns the numeric Row ID that is used as the primary key for
5
+ # this row in both the database and any {DBStruct::BogoHash}
6
+ # instances that may contain it.
7
+ attr_reader :rowid
8
+
9
+ # Creates a new instance of class, creates the corresponding
10
+ # database row and initializes the fields. This only works if the
11
+ # class is a subclass of `DBStruct`; `DBStruct` itself cannot be
12
+ # instantiated.
13
+ #
14
+ # If keyword arguments are given, they must correspond to the names
15
+ # of the fields and have values of the correct type or nil. Omitted
16
+ # arguments are equivalent to nil.
17
+ #
18
+ # The keyword argument `_id:` is intended for internal use only and
19
+ # should not be used.
20
+ def initialize(_id: nil, # Internal use only!
21
+ **kwargs)
22
+
23
+ check("Attempted to instantiate abstract class DBStruct.") {
24
+ self.class < DBStruct
25
+ }
26
+
27
+ @rowid = nil
28
+
29
+ # Case 1: existing row
30
+ if _id
31
+ check("Using '_id' with other arguments!") { kwargs.empty? }
32
+
33
+ @rowid = _id
34
+ check("Row '#{_id}' does not exist!", MissingRowError) { present? }
35
+
36
+ return
37
+ end
38
+
39
+ @rowid = dataset().insert({})
40
+ set_fields(**kwargs)
41
+ end
42
+
43
+ private
44
+
45
+ def dataset = self.class.dataset
46
+ def columns = self.class.columns
47
+
48
+ def check_field(name, value)
49
+ fld = self.class.fields_dict[name]
50
+ check("Unknown field name '#{name}'", FieldError) {
51
+ fld
52
+ }
53
+
54
+ check("Invalid field type: expecting #{fld.type}, got #{value.class}",
55
+ TypeError
56
+ ) {
57
+ value == nil || value.class == fld.type ||
58
+ (value == false && fld.type == TrueClass)
59
+ }
60
+
61
+ check("Underlying row-id (#{@rowid}) is not in the database.",
62
+ MissingRowError) { !deleted? }
63
+ end
64
+
65
+ public
66
+
67
+ # Test if the database row associated with `self` still exists.
68
+ # Inverse of `deleted?`.
69
+ def present? = !deleted?
70
+
71
+ # Test if the database row associated with `self` no longer exists.
72
+ # Inverse of `present?`
73
+ def deleted? = dataset().where(_id:@rowid).empty?
74
+
75
+ # Sets the value of the field named by `field` to the given value.
76
+ # `field` must be one of the declared fields and value must have the
77
+ # declared class.
78
+ def get_field(name)
79
+ check_field(name, nil)
80
+ return dataset[_id: @rowid][name]
81
+ end
82
+
83
+ # Retrieve the value of the field named by symbol `field`. Raises
84
+ # an exception if `field` is not one of the declared fields.
85
+ def set_field(name, value)
86
+ check_field(name, value)
87
+
88
+ dataset()
89
+ .where(_id: @rowid)
90
+ .update({name => value})
91
+
92
+ return value
93
+ end
94
+
95
+ # Set zero or more fields at once. Keywords must be the names of
96
+ # defined fields.
97
+ #
98
+ # The update is enclosed in an transaction.
99
+ def set_fields(**kwargs)
100
+ self.class.transaction {
101
+ kwargs.each{|k, v| set_field(k, v)}
102
+ }
103
+ return self
104
+ end
105
+
106
+ # Struct-like and hash-like
107
+
108
+ # Return the contents as an array of pairs of key and value
109
+ def to_a = self.columns.map{|key| [key, get_field(key)] }
110
+
111
+ # Return the contents as a hash
112
+ def to_h = to_a.to_h
113
+
114
+ # Equivalent to `get_field`
115
+ def [](field) = get_field(field)
116
+
117
+ # Equivalent to `set_field`
118
+ def []=(field, value)
119
+ return set_field(field, value)
120
+ end
121
+
122
+ # Equality
123
+
124
+ # Test for equality. Instances are equal if and only if they refer
125
+ # to the same database row and (as implied) are of the same class.
126
+ def eql?(other) = self.class == other.class && @rowid == other.rowid
127
+ alias == eql?
128
+
129
+ # Return a hash; here because we've also overridden {.eql?}
130
+ def hash = [self.class, @rowid].hash
131
+
132
+ # Etc
133
+
134
+ # Return a human-friendly description.
135
+ def inspect
136
+ max_line = 60
137
+
138
+ values = to_h.map{|k,v| "#{k}: #{v.inspect}"}.join(", ")
139
+ values = values[0..max_line] + "..." if values.size > max_line
140
+ return "#{self.class}.new(#{values})"
141
+ end
142
+ alias to_s inspect
143
+
144
+ end