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,301 @@
1
+
2
+
3
+
4
+ class << DBStruct
5
+
6
+ # Define a concrete DBStruct subclass and create the corresponding
7
+ # table if needed.
8
+ #
9
+ # The database and table are both accessed via Sequel classes
10
+ # (`Sequel::Database` and `Sequel::Dataset` respectively) and so are
11
+ # subject to their constraints and abilities. In addition, the
12
+ # underlying database **must** be SQLite.
13
+ #
14
+ # The new class is unnamed unless assigned to a global constant.
15
+ #
16
+ # If the corresponding table does not exist, it is created from the
17
+ # class's layout.
18
+ #
19
+ # If a table with a matching name already exists, it is assumed to
20
+ # have previously been created by this method with this layout.
21
+ # While there is some rudimentary consistency checking done to
22
+ # ensure that the expected columns are there, it is not exhaustive.
23
+ # If it is possible for an incompatible table with the same name to
24
+ # appear (e.g. due to version upgrades), you will need to use other
25
+ # mechanisms to detect this.
26
+ #
27
+ # The new class's layout (and corresponding table) is defined via
28
+ # the attached block. It is evaluated via `class_eval` and so may
29
+ # be used to define methods for the new subclass. In addition, it
30
+ # provides a minimal DSL for defining fields (i.e. database
31
+ # columns).
32
+ #
33
+ # The DSL adds two methods, `field` and `group`:
34
+ #
35
+ # field :name, type
36
+ # group :name, type
37
+ #
38
+ # `field` declares a new field and matching database column. It
39
+ # will create a database column named `name` with matching getter
40
+ # and setter methods that will access it. `name` must be a
41
+ # `Symbol`.
42
+ #
43
+ # Note that `name` **must** begin with a lower-case letter. This is
44
+ # a limitation imposed by `DBStruct` itself so that can add extra
45
+ # internal fields. (Currently, the only one used is called `_id`;
46
+ # this is the numeric primary key used as a unique row ID.)
47
+ #
48
+ # `type` may be any Ruby type that Sequel knows how to convert to a
49
+ # database column (see the link below). Unlike Sequel, DBStruct
50
+ # requires actual Ruby classes and will ensure that only objects of
51
+ # that specific type (or `nil`) may be stored in that field. This
52
+ # is enforced by the setter methods. (Exception: `TrueClass` and
53
+ # `FalseClass` are interchangeable.)
54
+ #
55
+ # `group` is like `field` except that the field is also treated as a
56
+ # category when used to select subsets with arguments to
57
+ # {.items}. `group` calls may be freely
58
+ # intermixed with `field` calls; however, their order is
59
+ # significant. Positional argumetns to `items` must match the order
60
+ # of `group` declarations.
61
+ #
62
+ # The new class is unnamed unless assigned to a global constant.
63
+ #
64
+ # @param db [Sequel::Database] the database.
65
+ #
66
+ # @param name [Symbol] the name of the dataset (i.e. underlying table)
67
+ #
68
+ # @return [Class] the new class.
69
+ #
70
+ # @see https://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html
71
+ def with(db, name, &body)
72
+
73
+ check("Argument 'db' is not a valid Sequel::Database") {
74
+ db.is_a?(Sequel::Database)
75
+ }
76
+
77
+ # It should be pretty straightfoward to support other databases,
78
+ # but I have neither the time nor resources to do so right now.
79
+ check("DBStruct currently only supports SQLite; #{db.database_type} " +
80
+ "is unsupported.") {
81
+ db.database_type == :sqlite
82
+ }
83
+
84
+ check("Must provide a block argument") { body }
85
+
86
+ sc = create_the_subclass(db, name, body)
87
+ create_table_if_needed(sc)
88
+ return sc
89
+ end
90
+
91
+ protected
92
+
93
+ #
94
+ # DSL functions
95
+ #
96
+
97
+ def group(name, type) = add_field(name, type, is_group: true)
98
+ def field(name, type) = add_field(name, type, is_group: false)
99
+
100
+ private
101
+
102
+ def add_field(name, type, is_group:)
103
+ check("Attempted to add field '#{name}' to '#{self.class}'") {
104
+ !@fields_dict.frozen?
105
+ }
106
+
107
+ check("Type '#{type}' is not a Ruby class!") {
108
+ type.is_a?(Class)
109
+ }
110
+
111
+ check("Attempt to redefine field '#{name}'") {
112
+ !@fields_dict.has_key?(name)
113
+ }
114
+
115
+ name.to_s =~ /^[a-z]/ or
116
+ oops("Invalid field name '#{name}: must begin with a lowercase letter")
117
+
118
+ # Normalize Booleans to TrueClass
119
+ type = TrueClass if type == FalseClass
120
+
121
+ fld = FieldDesc.new(
122
+ name: name,
123
+ type: type,
124
+ is_group: is_group,
125
+ ).freeze
126
+
127
+ @fields_dict[name] = fld
128
+ end
129
+
130
+ #
131
+ # Subclass creation
132
+ #
133
+
134
+ # @!visibility private
135
+ #
136
+ # Struct to hold information about a declared field.
137
+ FieldDesc = Struct.new(:name, :type, :is_group,
138
+ keyword_init: true)
139
+
140
+ def create_the_subclass(db, name, body)
141
+ sc = Class.new(self)
142
+
143
+ sc.instance_exec { @fields_dict = {} }
144
+ sc.class_eval(&body)
145
+ sc.instance_exec { @fields_dict.freeze }
146
+
147
+ sc.define_singleton_method(:db) { db }
148
+ sc.define_singleton_method(:name) { name }
149
+ sc.define_singleton_method(:dataset) { db[name] }
150
+
151
+ sc.define_singleton_method(:fields_dict) { @fields_dict }
152
+
153
+ flds = sc.fields_dict.values
154
+ sc.define_singleton_method(:fields) { flds }
155
+
156
+ columns = sc.fields.map{|fld| fld.name}.freeze
157
+ sc.define_singleton_method(:columns) { columns }
158
+
159
+ groups = sc.fields
160
+ .select{|fld| fld.is_group }
161
+ .map{|fld| fld.name }
162
+ .freeze
163
+ sc.define_singleton_method(:groups) { groups }
164
+
165
+ sc.fields.each{|fld|
166
+ create_accessor(sc, fld.name, assign: false)
167
+ create_accessor(sc, fld.name, assign: true)
168
+ }
169
+
170
+ return sc
171
+ end
172
+
173
+ # Create accessors unless they already were defined by the block.
174
+ def create_accessor(sc, field, assign:)
175
+ name = assign ? :"#{field}=" : field
176
+
177
+ # We skip this if it's defined in this class and not a subclass;
178
+ # this means it was (probably) defined in the block and the user
179
+ # (presumably) knows what they're doing.
180
+ return if sc.method_defined?(name, false)
181
+
182
+ # But it's an error if it was defined in a superclass
183
+ oops("Field '#{name}' overrides an existing method.") if
184
+ sc.method_defined?(name, true)
185
+
186
+ # And define the accessor
187
+ body = assign ?
188
+ proc{|val| set_field(field, val) } :
189
+ proc{ get_field(field) }
190
+ sc.define_method(name, &body)
191
+ end
192
+
193
+
194
+ #
195
+ # Table creation
196
+ #
197
+
198
+ def create_table_if_needed(sc)
199
+ sc.db.create_table?(sc.name) do
200
+ primary_key :_id
201
+
202
+ sc.fields.each{|fld|
203
+ column(fld.name, fld.type)
204
+ }
205
+ end
206
+
207
+ check_table_fields(sc)
208
+ end
209
+
210
+ def check_table_fields(sc)
211
+ want = sc.db[sc.name].columns.to_set - [:_id]
212
+ have = sc.columns.to_set
213
+
214
+ unless have == want
215
+ diff = (want - have).to_a.join(", ")
216
+ oops "Table '#{name}' is missing field(s): #{diff}"
217
+ end
218
+ end
219
+
220
+ #
221
+ # Table access
222
+ #
223
+
224
+ public
225
+
226
+ # Return a `DBStruct::BogoHash` containing some or all of the items
227
+ # in this table. If arguments are given, they must correspond to
228
+ # `group` fields. In that case, the returned `BogoHash` contains
229
+ # only those items whose values in those field match the argument.
230
+ #
231
+ # (This is roughly equivalent to using the Sequel `where` method to
232
+ # narrow a Dataset.)
233
+ #
234
+ # So
235
+ #
236
+ # Books.items("non-fiction", "dragons")
237
+ #
238
+ # will return a `BogoHash` that contains only entries whose first
239
+ # group field has the value "non-fiction" and whose second group
240
+ # field has the value 42.
241
+ #
242
+ # `nil` arguments are treated as wildcards and will match
243
+ # everything. Thus,
244
+ #
245
+ # Books.items(nil, "dragons")
246
+ #
247
+ # will return all items whose second group matches "dragons".
248
+ #
249
+ def items(*selectors)
250
+ check_not_base_class()
251
+
252
+ check("Too many group specifiers!") {
253
+ selectors.size <= self.groups.size
254
+ }
255
+
256
+ ds = get_group_selection_dataset(selectors) or
257
+ return nil
258
+
259
+ return DBStruct::BogoHash.new(self, ds, selectors)
260
+ end
261
+
262
+ private
263
+
264
+ def get_group_selection_dataset(selectors)
265
+ return self.dataset if selectors.empty?
266
+
267
+ query = self.groups().zip(selectors)
268
+ .select{|grp, sel| sel != nil}
269
+ .to_h
270
+ return self.dataset.where(**query)
271
+ end
272
+
273
+ public
274
+
275
+ # Convenience method; equivalent to self.items.where(...).
276
+ #
277
+ # See {DBStruct::BogoHash#where} for details.
278
+ def where(*cond, **kwcond, &block) =
279
+ self.items.where(*cond, **kwcond, &block)
280
+
281
+ # Helpers
282
+
283
+ # Evaluates `block` within a transaction. These can be safely
284
+ # nested and will (usually) roll back if an exception occurs in the
285
+ # block.
286
+ #
287
+ # This is trivial convenience wrapper around
288
+ # `Sequel::Database#transaction`; see its documentation for details.
289
+ def transaction(*args, **kwargs, &block) =
290
+ self.db.transaction(*args, **kwargs, &block)
291
+
292
+
293
+ protected
294
+
295
+ # Ensure 'self' is not an instance of DBStruct itself; this is a
296
+ # purely abstract base class.
297
+ def check_not_base_class
298
+ oops("This operation can only be performed on a subclass of DBStruct") if
299
+ self == DBStruct
300
+ end
301
+ end
@@ -0,0 +1,44 @@
1
+
2
+ class DBStruct
3
+
4
+ protected
5
+
6
+ # Base class for all exceptions thrown by `DBStruct`. Also used as
7
+ # the "misc" exception.
8
+ class Failure < RuntimeError; end
9
+
10
+ # Exception: malformed or missing column name
11
+ class FieldError < Failure ; end
12
+
13
+ # Exception: Wrong type for this field
14
+ class TypeError < Failure ; end
15
+
16
+ # Exception: Underlying row is missing
17
+ class MissingRowError < Failure ; end
18
+
19
+ private
20
+
21
+ # Error checking needs to be global, so we put it in a module and
22
+ # import it everywhere.
23
+ module ErrorHelpers
24
+
25
+ # Error check: if block evaluates to false, raise a Lite3::DBM::Error
26
+ # with the given message.
27
+ def check(message, exception = Failure, &block)
28
+ return if block && block.call
29
+ raise exception.new(message)
30
+ end
31
+
32
+ def oops(msg) raise Failure.new(msg) ; end
33
+ end
34
+
35
+ protected
36
+
37
+ include ErrorHelpers
38
+ class << self
39
+ include ErrorHelpers
40
+ end
41
+
42
+
43
+ end
44
+
@@ -0,0 +1,11 @@
1
+
2
+ class DBStruct
3
+ protected
4
+
5
+ # @!visibility private
6
+ module Util
7
+ # Place to put utility methods
8
+ end
9
+
10
+ include Util
11
+ end