require 'sequel' require 'weakref' module Lite3 # Wrapper around a Sequel::Database object. # # We do this instead of using them directly because transactions # happen at the handle level rather than the file level and this # lets us share the transaction across multiple tables in the same # file. # # In addition, we can use this to transparently close and reopen the # underlying database file when (e.g.) forking the process. # # Instances contain references to DBM objects using them. When the # set becomes empty, the handle is closed; adding a reference will # ensure the handle is open. class Handle attr_reader :path def initialize(path) @path = path @db = open_db(path) @refs = {} end private def open_db(path) return IS_JRUBY ? Sequel.connect("jdbc:sqlite:#{path}") : Sequel.sqlite(@path) end public def to_s "<#{self.class}:0x#{object_id.to_s(16)} path=#{@path}>" end alias inspect to_s # # References to the DBM object(s) using this handle. # # References are weak. scrub_refs! will remove all reclaimed refs # and close the handle if there are none left. (Note that this # doesn't preclude us from reopening the handle later, though. We # could keep Handles around longer if we want and reuse them, but we # don't.) # def addref(parent) @refs[parent.object_id] = WeakRef.new(parent) end def delref(parent) @refs.delete(parent.object_id) scrub_refs! end def scrub_refs! @refs.delete_if{|k,v| ! v.weakref_alive? } disconnect! if @refs.empty? end def live_refs scrub_refs! return @refs.size end # # Opening and closing # # Disconnect the underlying database handle. def disconnect! @db.disconnect end # # Transactions # # Perform &block in a transaction. See DBM.transaction. def transaction(&block) result = nil @db.transaction({}) { result = block.call } return result end # Test if there is currently a transaction in progress def transaction_active? return @db.in_transaction? end # # Table access; the common SQL idioms we care about. These all # deal with tables of key/value pairs. # # Create a table of key-value pairs if it does not already exist. def create_key_value_table(name) @db.create_table?(name) do String :key, primary_key: true String :value end end # Perform an upsert for the row with field 'key' def upsert(table, key, value) transaction { recs = @db[table].where(key: key) if recs.count == 0 @db[table].insert(key: key, value: value) elsif recs.count == 1 recs.update(value: value) else raise InternalError.new("Duplicate entry for key '#{key}'") end } return value end # Retrieve the 'value' field of the row with value 'key' in the given table. def lookup(table, key) row = @db[table].where(key:key).first return nil unless row return row[:value] end def clear_table(table) @db[table].delete end def delete(table, key) @db[table].where(key: key).delete end def get_size(table) return @db[table].count end # Backend for `each`; evaluates `block` on each row in `table` # with the undecoded key and value as arguments. It is *not* a # single transaction. # # We do this instead of using `Dataset.each` because the latter is # not guaranteed to be re-entrant. # # Each key/value pair is retrieved via a separate query so that it # is safe to access the database from inside the block. Items are # retrieved by rowid in increasing order. Since we preserve those, # modifications done in the block (probably) won't break things. # # This is (probably) not very fast but it's (probably) good enough # for most things. def tbl_each(table, &block) return if @db[table].count == 0 curr = -1 while true row = @db[table].where{rowid > curr} .limit(1) .select(:rowid, :key, :value) .first return unless row curr, key, value = *row.values block.call(key, value) end end # Wrapper around Dataset.each, with all the ensuing limitations. def tbl_each_fast(table, &block) @db[table].each(&block) end end # # Private classes # # Dummy `Handle` that throws an `Error` exception whenever something # tries to treat it as an open handle. This replaces a `DBM`'s # `Handle` object when `DBM.close` is called so that the error # message will be useful if something tries to access a closed # handle. class ClosedHandle def initialize(filename, table) @filename, @table = [filename, table] end # We clone the rest of Handle's interface with methods that throw # an Error. Handle.instance_methods(false).each { |name| next if method_defined? name define_method(name) { |*args| raise Error.new("Use of closed database at #{@filename}/#{@table}") } } end # Module to manage the collection of active Handle objects. See the # docs for `Lite3::SQL` for an overview; this module hold the actual # code and data. module HandlePool @@handles = {} # The hash of `Handle` objects keyed by filename # Retrieve the `Handle` associated with `filename`, creating it # first if necessary. `filename` is normalized with # `File.realpath` before using as a key and so is as good or bad # as that for detecting an existing file. def self.get(filename) # Scrub @@handles of all inactive Handles self.gc # We need to convert the filename to a canonical # form. `File.realpath` does this for us but only if the file # exists. If not, we use it on the parent directory instead and # use `File.join` to create the full path. if File.exist?(filename) File.file?(filename) or raise Error.new("Filename '#{filename}' exists but is not a file.") filename = File.realpath(filename) else dn = File.dirname(filename) File.directory?(dn) or raise Error.new("Parent directory '#{dn}' nonexistant or " + "not a directory.") filename = File.join(File.realpath(dn), File.basename(filename)) end @@handles[filename] = Handle.new(filename) unless @@handles.has_key?(filename) return @@handles[filename] end # Close all underlying database connections. def self.close_all Sequel::DATABASES.each(&:disconnect) end # Close and remove all Handle objects with no refs and return a # hash mapping the filename for each live Handle to the number of # DBM objects that currently reference it. Does **NOT** perform a # Ruby GC. def self.gc results = {} @@handles.select!{|path, handle| handle.scrub_refs! if handle.live_refs == 0 @@handles.delete(path) next false end results[path] = handle.live_refs true } return results end end private_constant :Handle, :ClosedHandle, :HandlePool end