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.
- checksums.yaml +7 -0
- data/README.md +125 -0
- data/Rakefile +35 -0
- data/db-struct.gemspec +37 -0
- data/doc/DBStruct/BogoHash.html +1384 -0
- data/doc/DBStruct/ErrorHelpers.html +288 -0
- data/doc/DBStruct/Failure.html +139 -0
- data/doc/DBStruct/FieldError.html +143 -0
- data/doc/DBStruct/MissingRowError.html +143 -0
- data/doc/DBStruct/TypeError.html +143 -0
- data/doc/DBStruct.html +1652 -0
- data/doc/_index.html +189 -0
- data/doc/class_list.html +54 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +58 -0
- data/doc/css/style.css +503 -0
- data/doc/file.README.html +187 -0
- data/doc/file_list.html +59 -0
- data/doc/frames.html +22 -0
- data/doc/index.html +187 -0
- data/doc/js/app.js +344 -0
- data/doc/js/full_list.js +242 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +342 -0
- data/doc/top-level-namespace.html +110 -0
- data/gem.deps.rb +7 -0
- data/lib/dbstruct.rb +29 -0
- data/lib/internal/bogohash.rb +226 -0
- data/lib/internal/dbstruct_base.rb +144 -0
- data/lib/internal/dbstruct_class.rb +301 -0
- data/lib/internal/error.rb +44 -0
- data/lib/internal/util.rb +11 -0
- data/spec/dbstruct_spec.rb +452 -0
- data/spec/spec_helper.rb +48 -0
- metadata +138 -0
@@ -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
|
+
|