class << DBStruct # Define a concrete DBStruct subclass and create the corresponding # table if needed. # # The database and table are both accessed via Sequel classes # (`Sequel::Database` and `Sequel::Dataset` respectively) and so are # subject to their constraints and abilities. In addition, the # underlying database **must** be SQLite. # # The new class is unnamed unless assigned to a global constant. # # If the corresponding table does not exist, it is created from the # class's layout. # # If a table with a matching name already exists, it is assumed to # have previously been created by this method with this layout. # While there is some rudimentary consistency checking done to # ensure that the expected columns are there, it is not exhaustive. # If it is possible for an incompatible table with the same name to # appear (e.g. due to version upgrades), you will need to use other # mechanisms to detect this. # # The new class's layout (and corresponding table) is defined via # the attached block. It is evaluated via `class_eval` and so may # be used to define methods for the new subclass. In addition, it # provides a minimal DSL for defining fields (i.e. database # columns). # # The DSL adds two methods, `field` and `group`: # # field :name, type # group :name, type # # `field` declares a new field and matching database column. It # will create a database column named `name` with matching getter # and setter methods that will access it. `name` must be a # `Symbol`. # # Note that `name` **must** begin with a lower-case letter. This is # a limitation imposed by `DBStruct` itself so that can add extra # internal fields. (Currently, the only one used is called `_id`; # this is the numeric primary key used as a unique row ID.) # # `type` may be any Ruby type that Sequel knows how to convert to a # database column (see the link below). Unlike Sequel, DBStruct # requires actual Ruby classes and will ensure that only objects of # that specific type (or `nil`) may be stored in that field. This # is enforced by the setter methods. (Exception: `TrueClass` and # `FalseClass` are interchangeable.) # # `group` is like `field` except that the field is also treated as a # category when used to select subsets with arguments to # {.items}. `group` calls may be freely # intermixed with `field` calls; however, their order is # significant. Positional argumetns to `items` must match the order # of `group` declarations. # # The new class is unnamed unless assigned to a global constant. # # @param db [Sequel::Database] the database. # # @param name [Symbol] the name of the dataset (i.e. underlying table) # # @return [Class] the new class. # # @see https://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html def with(db, name, &body) check("Argument 'db' is not a valid Sequel::Database") { db.is_a?(Sequel::Database) } # It should be pretty straightfoward to support other databases, # but I have neither the time nor resources to do so right now. check("DBStruct currently only supports SQLite; #{db.database_type} " + "is unsupported.") { db.database_type == :sqlite } check("Must provide a block argument") { body } sc = create_the_subclass(db, name, body) create_table_if_needed(sc) return sc end protected # # DSL functions # def group(name, type) = add_field(name, type, is_group: true) def field(name, type) = add_field(name, type, is_group: false) private def add_field(name, type, is_group:) check("Attempted to add field '#{name}' to '#{self.class}'") { !@fields_dict.frozen? } check("Type '#{type}' is not a Ruby class!") { type.is_a?(Class) } check("Attempt to redefine field '#{name}'") { !@fields_dict.has_key?(name) } name.to_s =~ /^[a-z]/ or oops("Invalid field name '#{name}: must begin with a lowercase letter") # Normalize Booleans to TrueClass type = TrueClass if type == FalseClass fld = FieldDesc.new( name: name, type: type, is_group: is_group, ).freeze @fields_dict[name] = fld end # # Subclass creation # # @!visibility private # # Struct to hold information about a declared field. FieldDesc = Struct.new(:name, :type, :is_group, keyword_init: true) def create_the_subclass(db, name, body) sc = Class.new(self) sc.instance_exec { @fields_dict = {} } sc.class_eval(&body) sc.instance_exec { @fields_dict.freeze } sc.define_singleton_method(:db) { db } sc.define_singleton_method(:name) { name } sc.define_singleton_method(:dataset) { db[name] } sc.define_singleton_method(:fields_dict) { @fields_dict } flds = sc.fields_dict.values sc.define_singleton_method(:fields) { flds } columns = sc.fields.map{|fld| fld.name}.freeze sc.define_singleton_method(:columns) { columns } groups = sc.fields .select{|fld| fld.is_group } .map{|fld| fld.name } .freeze sc.define_singleton_method(:groups) { groups } sc.fields.each{|fld| create_accessor(sc, fld.name, assign: false) create_accessor(sc, fld.name, assign: true) } return sc end # Create accessors unless they already were defined by the block. def create_accessor(sc, field, assign:) name = assign ? :"#{field}=" : field # We skip this if it's defined in this class and not a subclass; # this means it was (probably) defined in the block and the user # (presumably) knows what they're doing. return if sc.method_defined?(name, false) # But it's an error if it was defined in a superclass oops("Field '#{name}' overrides an existing method.") if sc.method_defined?(name, true) # And define the accessor body = assign ? proc{|val| set_field(field, val) } : proc{ get_field(field) } sc.define_method(name, &body) end # # Table creation # def create_table_if_needed(sc) sc.db.create_table?(sc.name) do primary_key :_id sc.fields.each{|fld| column(fld.name, fld.type) } end check_table_fields(sc) end def check_table_fields(sc) want = sc.db[sc.name].columns.to_set - [:_id] have = sc.columns.to_set unless have == want diff = (want - have).to_a.join(", ") oops "Table '#{name}' is missing field(s): #{diff}" end end # # Table access # public # Return a `DBStruct::BogoHash` containing some or all of the items # in this table. If arguments are given, they must correspond to # `group` fields. In that case, the returned `BogoHash` contains # only those items whose values in those field match the argument. # # (This is roughly equivalent to using the Sequel `where` method to # narrow a Dataset.) # # So # # Books.items("non-fiction", "dragons") # # will return a `BogoHash` that contains only entries whose first # group field has the value "non-fiction" and whose second group # field has the value 42. # # `nil` arguments are treated as wildcards and will match # everything. Thus, # # Books.items(nil, "dragons") # # will return all items whose second group matches "dragons". # def items(*selectors) check_not_base_class() check("Too many group specifiers!") { selectors.size <= self.groups.size } ds = get_group_selection_dataset(selectors) or return nil return DBStruct::BogoHash.new(self, ds, selectors) end private def get_group_selection_dataset(selectors) return self.dataset if selectors.empty? query = self.groups().zip(selectors) .select{|grp, sel| sel != nil} .to_h return self.dataset.where(**query) end public # Convenience method; equivalent to self.items.where(...). # # See {DBStruct::BogoHash#where} for details. def where(*cond, **kwcond, &block) = self.items.where(*cond, **kwcond, &block) # Helpers # Evaluates `block` within a transaction. These can be safely # nested and will (usually) roll back if an exception occurs in the # block. # # This is trivial convenience wrapper around # `Sequel::Database#transaction`; see its documentation for details. def transaction(*args, **kwargs, &block) = self.db.transaction(*args, **kwargs, &block) protected # Ensure 'self' is not an instance of DBStruct itself; this is a # purely abstract base class. def check_not_base_class oops("This operation can only be performed on a subclass of DBStruct") if self == DBStruct end end