require 'date' require 'drb' require 'csv' require 'fileutils' # KirbyBase is a class that allows you to create and manipulate simple, # plain-text databases. You can use it in either a single-user or # client-server mode. You can select records for retrieval/updating using # code blocks. # # Author:: Jamey Cribbs (mailto:jcribbs@twmi.rr.com) # Homepage:: http://www.netpromi.com/kirbybase.html # Copyright:: Copyright (c) 2005 NetPro Technologies, LLC # License:: Distributes under the same terms as Ruby # History: # 2005-03-28:: Version 2.0 # * This is a completely new version. The interface has changed # dramatically. # 2005-04-11:: Version 2.1 # * Changed the interface to KirbyBase#new and KirbyBase#create_table. You # now specify arguments via a code block or as part of the argument list. # * Added the ability to specify a class at table creation time. # Thereafter, whenever you do a #select, the result set will be an array # of instances of that class, instead of instances of Struct. You can # also use instances of this class as the argument to KBTable#insert, # KBTable#update, and KBTable#set. # * Added the ability to encrypt a table so that it is no longer stored as # a plain-text file. # * Added the ability to explicity specify that you want a result set to be # sorted in ascending order. # * Added the ability to import a csv file into an existing table. # * Added the ability to select a record as if the table were a Hash with # it's key being the recno field. # * Added the ability to update a record as if the table were a Hash with # it's key being the recno field. # 2005-05-02:: Version 2.2 # * By far the biggest change in this version is that I have completely # redesigned the internal structure of the database code. Because the # KirbyBase and KBTable classes were too tightly coupled, I have created # a KBEngine class and moved all low-level I/O logic and locking logic # to this class. This allowed me to restructure the KirbyBase class to # remove all of the methods that should have been private, but couldn't be # because of the coupling to KBTable. In addition, it has allowed me to # take all of the low-level code that should not have been in the KBTable # class and put it where it belongs, as part of the underlying engine. I # feel that the design of KirbyBase is much cleaner now. No changes were # made to the class interfaces, so you should not have to change any of # your code. # * Changed str_to_date and str_to_datetime to use Date#parse method. # * Changed #pack method so that it no longer reads the whole file into # memory while packing it. # * Changed code so that special character sequences like &linefeed; can be # part of input data and KirbyBase will not interpret it as special # characters. # #--------------------------------------------------------------------------- # KirbyBase #--------------------------------------------------------------------------- class KirbyBase include DRb::DRbUndumped attr_reader :engine attr_accessor :connect_type, :host, :port, :path, :ext #----------------------------------------------------------------------- # initialize #----------------------------------------------------------------------- #++ # Create a new database instance. # # *connect_type*:: Symbol (:local, :client, :server) specifying role to # play. # *host*:: String containing IP address or DNS name of server hosting # database. (Only valid if connect_type is :client.) # *port*:: Integer specifying port database server is listening on. # (Only valid if connect_type is :client.) # *path*:: String specifying path to location of database tables. # *ext*:: String specifying extension of table files. # def initialize(connect_type=:local, host=nil, port=nil, path='./', ext='.tbl') @connect_type = connect_type @host = host @port = port @path = path @ext = ext # See if user specified any method arguments via a code block. yield self if block_given? # After the yield, make sure the user doesn't change any of these # instance variables. class << self private :connect_type=, :host=, :path=, :ext= end # Did user supply full and correct arguments to method? raise ArgumentError, 'Invalid connection type specified' unless ( [:local, :client, :server].include?(@connect_type)) raise "Must specify hostname or IP address!" if \ @connect_type == :client and @host.nil? raise "Must specify port number!" if @connect_type == :client and \ @port.nil? raise "Invalid path!" if @path.nil? raise "Invalid extension!" if @ext.nil? # If running as a client, start druby and connect to server. if client? DRb.start_service() @server = DRbObject.new(nil, 'druby://%s:%d' % [@host, @port]) @engine = @server.engine @path = @server.path @ext = @server.ext else @engine = KBEngine.create_called_from_database_instance(self) end end #----------------------------------------------------------------------- # server? #----------------------------------------------------------------------- #++ # Is this running as a server? # def server? @connect_type == :server end #----------------------------------------------------------------------- # client? #----------------------------------------------------------------------- #++ # Is this running as a client? # def client? @connect_type == :client end #----------------------------------------------------------------------- # local? #----------------------------------------------------------------------- #++ # Is this running in single-user, embedded mode? # def local? @connect_type == :local end #----------------------------------------------------------------------- # tables #----------------------------------------------------------------------- #++ # Return an array containing the names of all tables in this database. # def tables return @engine.tables end #----------------------------------------------------------------------- # get_table #----------------------------------------------------------------------- #++ # Return a reference to the requested table. # *name*:: Symbol or string of table name. # def get_table(name) raise('Do not call this method from a server instance!') if server? raise('Table not found!') unless table_exists?(name) return KBTable.create_called_from_database_instance(self, name.to_sym, File.join(@path, name.to_s + @ext)) end #----------------------------------------------------------------------- # create_table #----------------------------------------------------------------------- #++ # Create new table and return a reference to the new table. # *name*:: Symbol or string of table name. # *field_defs*:: List of field names (Symbols) and field types (Symbols) # *Block*:: Optional code block allowing you to set the following: # *encrypt*:: true/false specifying whether table should be encrypted. # *record_class*:: Class or String specifying the user create class that # will be associated with table records. # def create_table(name=nil, *field_defs) raise "Can't call #create_table from server!" if server? t_struct = Struct.new(:name, :field_defs, :encrypt, :record_class) t = t_struct.new t.name = name t.field_defs = field_defs t.encrypt = false t.record_class = 'Struct' yield t if block_given? raise "No table name specified!" if t.name.nil? raise "No table field definitions specified!" if t.field_defs.nil? @engine.new_table(t.name, t.field_defs, t.encrypt, t.record_class.to_s) return get_table(t.name) end #----------------------------------------------------------------------- # drop_table #----------------------------------------------------------------------- #++ # Delete a table. # # *tablename*:: Symbol or string of table name. # def drop_table(tablename) return @engine.delete_table(tablename) end #----------------------------------------------------------------------- # table_exists? #----------------------------------------------------------------------- #++ # Return true if table exists. # # *tablename*:: Symbol or string of table name. # def table_exists?(tablename) return @engine.table_exists?(tablename) end end #--------------------------------------------------------------------------- # KBEngine #--------------------------------------------------------------------------- class KBEngine include DRb::DRbUndumped EN_STR = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + \ '0123456789.+-,$:|&;_ ' EN_STR_LEN = EN_STR.length EN_KEY1 = ")2VER8GE\"87-E\n" #*** DO NOT CHANGE *** EN_KEY = EN_KEY1.unpack("u")[0] EN_KEY_LEN = EN_KEY.length # Regular expression used to determine if field needs to be # encoded. ENCODE_RE = /&|\n|\r|\032|\|/ # Make constructor private. private_class_method :new def KBEngine.create_called_from_database_instance(db) return new(db) end def initialize(db) @db = db # This hash will hold the table locks if in client/server mode. @mutex_hash = {} if @db.server? end #----------------------------------------------------------------------- # table_exists? #----------------------------------------------------------------------- #++ # Return true if table exists. # # *tablename*:: Symbol or string of table name. # def table_exists?(tablename) return File.exists?(File.join(@db.path, tablename.to_s + @db.ext)) end #----------------------------------------------------------------------- # tables #----------------------------------------------------------------------- #++ # Return an array containing the names of all tables in this database. # def tables list = [] Dir.foreach(@db.path) { |filename| list << File.basename(filename, '.*').to_sym if \ filename =~ Regexp.new(@db.ext) } return list end #----------------------------------------------------------------------- # new_table #----------------------------------------------------------------------- #++ # Create physical file holding table. This table should not be directly # called in your application, but only called by #create_table. # # *name*:: Symbol or string of table name. # *field_defs*:: List of field names (Symbols) and field types (Symbols) # *encrypt*:: true/false specifying whether table should be encrypted. # *record_class*:: Class or String specifying the user create class that # def new_table(name, field_defs, encrypt, record_class) # Can't create a table that already exists! raise "Table #{name.to_s} already exists!" if table_exists?(name) raise "Must have a field type for each field name" \ unless field_defs.size.remainder(2) == 0 temp_field_defs = [] (0...field_defs.size).step(2) { |x| raise "Invalid field type: #{field_defs[x+1]}" unless \ KBTable.valid_field_type?(field_defs[x+1]) temp_field_defs << \ "#{field_defs[x].to_s}:#{field_defs[x+1]}" } # Header rec consists of last record no. used, delete count, and # all field names/types. Here, I am inserting the 'recno' field # at the beginning of the fields. header_rec = ['000000', '000000', record_class, 'recno:Integer', temp_field_defs].join('|') header_rec = 'Z' + encrypt_str(header_rec) if encrypt begin fptr = open(File.join(@db.path, name.to_s + @db.ext), 'w') fptr.write(header_rec + "\n") ensure fptr.close end end #----------------------------------------------------------------------- # delete_table #----------------------------------------------------------------------- #++ # Delete a table. # # *tablename*:: Symbol or string of table name. # def delete_table(tablename) with_write_lock(tablename) do File.delete(File.join(@db.path, tablename.to_s + @db.ext)) return true end end #---------------------------------------------------------------------- # get_total_recs #---------------------------------------------------------------------- #++ # Return total number of non-deleted records in table. # # *table*:: Table instance. # def get_total_recs(table) return get_recs(table).size end #----------------------------------------------------------------------- # get_header_vars #----------------------------------------------------------------------- #++ # Returns array containing first line of file. # # *table*:: Table instance. # def get_header_vars(table) with_table(table) do |fptr| line = get_header_record(table, fptr) last_rec_no, del_ctr, record_class, *flds = line.split('|') field_names = flds.collect { |x| x.split(':')[0].to_sym } field_types = flds.collect { |x| x.split(':')[1].to_sym } return [table.encrypted?, last_rec_no.to_i, del_ctr.to_i, record_class, field_names, field_types] end end #----------------------------------------------------------------------- # get_recs #----------------------------------------------------------------------- #++ # Return array of all table records (arrays). # # *table*:: Table instance. # def get_recs(table) encrypted = table.encrypted? recs = [] with_table(table) do |fptr| begin # Skip header rec. fptr.readline # Loop through table. while true # Record current position in table. Then read first # detail record. fpos = fptr.tell line = fptr.readline line = unencrypt_str(line) if encrypted line.strip! # If blank line (i.e. 'deleted'), skip it. next if line == '' # Split the line up into fields. rec = line.split('|', -1) rec << fpos << line.length recs << rec end # Here's how we break out of the loop... rescue EOFError end return recs end end #----------------------------------------------------------------------- # insert_record #----------------------------------------------------------------------- # def insert_record(table, rec) with_write_locked_table(table) do |fptr| # Auto-increment the record number field. rec_no = incr_rec_no_ctr(table, fptr) # Insert the newly created record number value at the beginning # of the field values. rec[0] = rec_no # Encode any special characters (like newlines) before writing # out the record. write_record(table, fptr, 'end', rec.collect { |r| encode_str(r) }.join('|')) # Return the record number of the newly created record. return rec_no end end #----------------------------------------------------------------------- # update_records #----------------------------------------------------------------------- # def update_records(table, recs) with_write_locked_table(table) do |fptr| recs.each do |rec| line = rec[:rec].collect { |r| encode_str(r) }.join('|') # This doesn't actually 'delete' the line, it just # makes it all spaces. That way, if the updated # record is the same or less length than the old # record, we can write the record back into the # same spot. If the updated record is greater than # the old record, we will leave the now spaced-out # line and write the updated record at the end of # the file. write_record(table, fptr, rec[:fpos], ' ' * rec[:line_length]) if line.length > rec[:line_length] write_record(table, fptr, 'end', line) incr_del_ctr(table, fptr) else write_record(table, fptr, rec[:fpos], line) end end # Return the number of records updated. return recs.size end end #----------------------------------------------------------------------- # delete_records #----------------------------------------------------------------------- # def delete_records(table, recs) with_write_locked_table(table) do |fptr| recs.each do |rec| # Go to offset within the file where the record is and # replace it with all spaces. write_record(table, fptr, rec.fpos, ' ' * rec.line_length) incr_del_ctr(table, fptr) end # Return the number of records deleted. return recs.size end end #----------------------------------------------------------------------- # pack_table #----------------------------------------------------------------------- # def pack_table(table) with_write_lock(table) do fptr = open(table.filename, 'r') new_fptr = open(table.filename+'temp', 'w') line = fptr.readline.chomp # Reset the delete counter in the header rec to 0. if line[0..0] == 'Z' header_rec = unencrypt_str(line[1..-1]).split('|') header_rec[1] = '000000' new_fptr.write('Z' + encrypt_str(header_rec.join('|')) + "\n") else header_rec = line.split('|') header_rec[1] = '000000' new_fptr.write(header_rec.join('|') + "\n") end lines_deleted = 0 begin while true line = fptr.readline if table.encrypted? temp_line = unencrypt_str(line) else temp_line = line end if temp_line.strip == '' lines_deleted += 1 else new_fptr.write(line) end end # Here's how we break out of the loop... rescue EOFError end # Close the table and release the write lock. fptr.close new_fptr.close File.delete(table.filename) FileUtils.mv(table.filename+'temp', table.filename) # Return the number of deleted records that were removed. return lines_deleted end end #----------------------------------------------------------------------- # PRIVATE METHODS #----------------------------------------------------------------------- private #----------------------------------------------------------------------- # with_table #----------------------------------------------------------------------- # def with_table(table, access='r') begin yield fptr = open(table.filename, access) ensure fptr.close end end #----------------------------------------------------------------------- # with_write_lock #----------------------------------------------------------------------- # def with_write_lock(table) begin write_lock(table.name) if @db.server? yield ensure write_unlock(table.name) if @db.server? end end #----------------------------------------------------------------------- # with_write_locked_table #----------------------------------------------------------------------- # def with_write_locked_table(table, access='r+') begin write_lock(table.name) if @db.server? yield fptr = open(table.filename, access) ensure fptr.close write_unlock(table.name) if @db.server? end end #----------------------------------------------------------------------- # write_lock #----------------------------------------------------------------------- # def write_lock(tablename) # Unless an key already exists in the hash holding mutex records # for this table, create a write key for this table in the mutex # hash. Then, place a lock on that mutex. @mutex_hash[tablename] = Mutex.new unless ( @mutex_hash.has_key?(tablename)) @mutex_hash[tablename].lock return true end #---------------------------------------------------------------------- # write_unlock #---------------------------------------------------------------------- # def write_unlock(tablename) # Unlock the write mutex for this table. @mutex_hash[tablename].unlock return true end #---------------------------------------------------------------------- # write_record #---------------------------------------------------------------------- # def write_record(table, fptr, pos, record) if table.encrypted? temp_rec = encrypt_str(record) else temp_rec = record end # If record is to be appended, go to end of table and write # record, adding newline character. if pos == 'end' fptr.seek(0, IO::SEEK_END) fptr.write(temp_rec + "\n") else # Otherwise, overwrite another record (that's why we don't # add the newline character). fptr.seek(pos) fptr.write(temp_rec) end end #---------------------------------------------------------------------- # write_header_record #---------------------------------------------------------------------- # def write_header_record(table, fptr, record) fptr.seek(0) if table.encrypted? fptr.write('Z' + encrypt_str(record) + "\n") else fptr.write(record + "\n") end end #---------------------------------------------------------------------- # get_header_record #---------------------------------------------------------------------- # def get_header_record(table, fptr) fptr.seek(0) if table.encrypted? return unencrypt_str(fptr.readline[1..-1].chomp) else return fptr.readline.chomp end end #----------------------------------------------------------------------- # reset_rec_no_ctr #----------------------------------------------------------------------- # def reset_rec_no_ctr(table, fptr) last_rec_no, rest_of_line = get_header_record(table, fptr).split( '|', 2) write_header_record(table, fptr, ['%06d' % 0, rest_of_line].join( '|')) return true end #----------------------------------------------------------------------- # incr_rec_no_ctr #----------------------------------------------------------------------- # def incr_rec_no_ctr(table, fptr) last_rec_no, rest_of_line = get_header_record(table, fptr).split( '|', 2) last_rec_no = last_rec_no.to_i + 1 write_header_record(table, fptr, ['%06d' % last_rec_no, rest_of_line].join('|')) # Return the new recno. return last_rec_no end #----------------------------------------------------------------------- # incr_del_ctr #----------------------------------------------------------------------- # def incr_del_ctr(table, fptr) last_rec_no, del_ctr, rest_of_line = get_header_record(table, fptr).split('|', 3) del_ctr = del_ctr.to_i + 1 write_header_record(table, fptr, [last_rec_no, '%06d' % del_ctr, rest_of_line].join('|')) return true end #----------------------------------------------------------------------- # encrypt_str #----------------------------------------------------------------------- #++ # Returns an encrypted string, using the Vignere Cipher. # # *s*:: String to encrypt. # def encrypt_str(s) new_str = '' i_key = -1 s.each_byte do |c| if i_key < EN_KEY_LEN - 1 i_key += 1 else i_key = 0 end if EN_STR.index(c.chr).nil? new_str << c.chr next end i_from_str = EN_STR.index(EN_KEY[i_key]) + EN_STR.index(c.chr) i_from_str = i_from_str - EN_STR_LEN if i_from_str >= EN_STR_LEN new_str << EN_STR[i_from_str] end return new_str end #----------------------------------------------------------------------- # unencrypt_str #----------------------------------------------------------------------- #++ # Returns an unencrypted string, using the Vignere Cipher. # # *s*:: String to unencrypt. # def unencrypt_str(s) new_str = '' i_key = -1 s.each_byte do |c| if i_key < EN_KEY_LEN - 1 i_key += 1 else i_key = 0 end if EN_STR.index(c.chr).nil? new_str << c.chr next end i_from_str = EN_STR.index(c.chr) - EN_STR.index(EN_KEY[i_key]) i_from_str = i_from_str + EN_STR_LEN if i_from_str < 0 new_str << EN_STR[i_from_str] end return new_str end #----------------------------------------------------------------------- # encode_str #----------------------------------------------------------------------- #++ # Replace characters in string that can cause problems when storing. # # *s*:: String to be encoded. # def encode_str(s) if s =~ ENCODE_RE return s.gsub("&", '&').gsub("\n", '&linefeed;').gsub( "\r", '&carriage_return;').gsub("\032", '&substitute;').gsub( "|", '&pipe;') else return s end end end #--------------------------------------------------------------------------- # KBTable #--------------------------------------------------------------------------- class KBTable include DRb::DRbUndumped # Make constructor private. KBTable instances should only be created # from KirbyBase#get_table. private_class_method :new # Regular expression used to determine if field needs to be # un-encoded. UNENCODE_RE = /&(?:amp|linefeed|carriage_return|substitute|pipe);/ TYPE_CONV = { :Integer => :Integer, :Float => :Float, :String => :unencode_str, :Boolean => :str_to_bool, :Date => :str_to_date, :DateTime => :str_to_datetime } attr_reader :filename, :name #----------------------------------------------------------------------- # KBTable.valid_field_type #----------------------------------------------------------------------- #++ # Return true if valid field type. # # *field_type*:: Symbol specifying field type. # def KBTable.valid_field_type?(field_type) TYPE_CONV.key?(field_type) end #----------------------------------------------------------------------- # create_called_from_database_instance #----------------------------------------------------------------------- #++ # Return a new instance of KBTable. Should never be called directly by # your application. Should only be called from KirbyBase#get_table. # # *db*:: KirbyBase instance. # *name*:: Symbol specifying table name. # *filename*:: String specifying filename of physical file that holds # table. # def KBTable.create_called_from_database_instance(db, name, filename) return new(db, name, filename) end # This has been declared private so user's cannot create new instances # of KBTable from their application. A user gets a handle to a KBTable # instance by calling KirbyBase#get_table for an existing table or # KirbyBase#create_table for a new table. def initialize(db, name, filename) @db = db @name = name @filename = filename @encrypted = false # Alias delete_all to clear method. alias delete_all clear update_header_vars end #----------------------------------------------------------------------- # encrypted? #----------------------------------------------------------------------- #++ # Returns true if table is encrypted. # def encrypted? if @encrypted return true else return false end end #----------------------------------------------------------------------- # field_names #----------------------------------------------------------------------- #++ # Return array containing table field names. # def field_names update_header_vars return @field_names end #----------------------------------------------------------------------- # field_types #----------------------------------------------------------------------- #++ # Return array containing table field types. # def field_types update_header_vars return @field_types end #----------------------------------------------------------------------- # insert #----------------------------------------------------------------------- #++ # Insert a new record into a table, return unique record number. # # *data*:: Array, Hash, Struct instance containing field values of # new record. # *insert_proc*:: Proc instance containing insert code. This and the # data parameter are mutually exclusive. # def insert(*data, &insert_proc) raise 'Cannot specify both a hash/array/struct and a ' + \ 'proc for method #insert!' unless data.empty? or insert_proc.nil? raise 'Must specify either hash/array/struct or insert ' + \ 'proc for method #insert!' if data.empty? and insert_proc.nil? # Update the header variables. update_header_vars # Convert input, which could be an array, a hash, or a Struct # into a common format (i.e. hash). if data.empty? input_rec = convert_input_data(insert_proc) else input_rec = convert_input_data(data) end # Check the field values to make sure they are proper types. validate_input(input_rec) return @db.engine.insert_record(self, @field_names.collect { |f| input_rec.fetch(f, '') }) end #----------------------------------------------------------------------- # update_all #----------------------------------------------------------------------- #++ # Return array of records (Structs) to be updated, in this case all # records. # # *updates*:: Hash or Struct containing updates. # def update_all(*updates) update(*updates) { true } end #----------------------------------------------------------------------- # update #----------------------------------------------------------------------- #++ # Return array of records (Structs) to be updated based on select cond. # # *updates*:: Hash or Struct containing updates. # *select_cond*:: Proc containing code to select records to update. # def update(*updates, &select_cond) raise ArgumentError, "Must specify select condition code " + \ "block. To update all records, use #update_all instead." if \ select_cond.nil? # Update the header variables. update_header_vars # Get all records that match the selection criteria and # return them in an array. result_set = get_matches(:update, @field_names, select_cond) return result_set if updates.empty? set(result_set, updates) end #----------------------------------------------------------------------- # []= #----------------------------------------------------------------------- #++ # Update record whose recno field equals index. # # *index*:: Integer specifying recno you wish to select. # *updates*:: Hash, Struct, or Array containing updates. # def []=(index, updates) return update(updates) { |r| r.recno == index } end #----------------------------------------------------------------------- # set #----------------------------------------------------------------------- #++ # Set fields of records to updated values. Returns number of records # updated. # # *recs*:: Array of records (Structs) that will be updated. # *data*:: Hash, Struct, Proc containing updates. # def set(recs, data) # Update the header variables. update_header_vars # Convert updates, which could be an array, a hash, or a Struct # into a common format (i.e. hash). update_rec = convert_input_data(data) validate_input(update_rec) updated_recs = [] recs.each do |rec| updated_rec = {} updated_rec[:rec] = @field_names.collect { |f| if update_rec.has_key?(f) update_rec[f] else rec.send(f) end } updated_rec[:fpos] = rec.fpos updated_rec[:line_length] = rec.line_length updated_recs << updated_rec end @db.engine.update_records(self, updated_recs) # Return the number of records updated. return recs.size end #----------------------------------------------------------------------- # delete #----------------------------------------------------------------------- #++ # Delete records from table and return # deleted. # # *select_cond*:: Proc containing code to select records. # def delete(&select_cond) raise ArgumentError, "Must specify select condition code " + \ "block. To delete all records, use #clear instead." if \ select_cond.nil? # Update the header variables. update_header_vars # Get all records that match the selection criteria and # return them in an array. result_set = get_matches(:delete, [], select_cond) @db.engine.delete_records(self, result_set) # Return the number of records deleted. return result_set.size end #----------------------------------------------------------------------- # clear #----------------------------------------------------------------------- #++ # Delete all records from table. You can also use #delete_all. # # *reset_recno_ctr*:: true/false specifying whether recno counter should # be reset to 0. # def clear(reset_recno_ctr=true) delete { true } pack @db.engine.reset_recno_ctr if reset_recno_ctr end #----------------------------------------------------------------------- # [] #----------------------------------------------------------------------- #++ # Return the record(s) whose recno field is included in index. # # *index*:: Array of Integer(s) specifying recno(s) you wish to select. # def [](*index) recs = select { |r| index.include?(r.recno) } if recs.size == 1 return recs[0] else return recs end end #----------------------------------------------------------------------- # select #----------------------------------------------------------------------- #++ # Return array of records (Structs) matching select conditions. # # *filter*:: List of field names (Symbols) to include in result set. # *select_cond*:: Proc containing select code. # def select(*filter, &select_cond) # Declare these variables before the code block so they don't go # after the code block is done. result_set = [] # Update the header variables. update_header_vars # Validate that all names in filter are valid field names. validate_filter(filter) filter = @field_names if filter.empty? # Get all records that match the selection criteria and # return them in an array of Struct instances. return get_matches(:select, filter, select_cond) end #----------------------------------------------------------------------- # pack #----------------------------------------------------------------------- #++ # Remove blank records from table, return total removed. # def pack return @db.engine.pack_table(self) end #----------------------------------------------------------------------- # total_recs #----------------------------------------------------------------------- #++ # Return total number of undeleted (blank) records in table. # def total_recs return @db.engine.get_total_recs(self) end #----------------------------------------------------------------------- # import_csv #----------------------------------------------------------------------- #++ # Import csv file into table. # # *csv_filename*:: filename of csv file to import. # def import_csv(csv_filename) type_convs = @field_types.collect { |x| TYPE_CONV[x] } CSV.open(csv_filename, 'r') do |row| temp_row = [] (0...@field_names.size-1).each { |x| if row[x].to_s == '' temp_row << nil else temp_row << send(type_convs[x+1], row[x].to_s) end } insert(*temp_row) end end #----------------------------------------------------------------------- # PRIVATE METHODS #----------------------------------------------------------------------- private #----------------------------------------------------------------------- # str_to_date #----------------------------------------------------------------------- #++ # Convert a String to a Date. # # *s*:: String to be converted. # def str_to_date(s) # Convert a string to a date object. NOTE: THIS IS SLOW!!!! # If you can, just define any date fields in the database as # string fields. Queries will be much faster if you do. return Date.parse(s) end #----------------------------------------------------------------------- # str_to_datetime #----------------------------------------------------------------------- #++ # Convert a String to a DateTime. # # *s*:: String to be converted. # def str_to_datetime(s) # Convert a string to a datetime object. NOTE: THIS IS SLOW!!!! # If you can, just define any datetime fields in the database as # string fields. Queries will be much faster if you do. return DateTime.parse(s) end #----------------------------------------------------------------------- # str_to_bool #----------------------------------------------------------------------- #++ # Convert a String to a TrueClass or FalseClass. # # *s*:: String to be converted. # def str_to_bool(s) if s == 'false' or s.nil? return false else return true end end #----------------------------------------------------------------------- # validate_filter #----------------------------------------------------------------------- #++ # Check that filter contains valid field names. # # *filter*:: Array holding field names to include in result set. # def validate_filter(filter) # Each field in the filter array must be a valid fieldname in the # table. filter.each { |x| raise "Invalid field name: #{x}" unless ( @field_names.include?(x)) } end #----------------------------------------------------------------------- # convert_input_data #----------------------------------------------------------------------- #++ # Convert data passed to #input, #update, or #set to a common format. # # *values*:: Proc, user class, hash, or array holding input values. # def convert_input_data(values) if values.class == Proc tbl_struct = Struct.new(*@field_names[1..-1]) tbl_rec = tbl_struct.new begin values.call(tbl_rec) rescue NoMethodError raise "Invalid field name in code block: %s" % $! end temp_hash = {} @field_names[1..-1].collect { |f| temp_hash[f] = tbl_rec[f] unless tbl_rec[f].nil? } return temp_hash elsif values[0].class.to_s == @record_class temp_hash = {} @field_names[1..-1].collect { |f| temp_hash[f] = values[0].send(f) if values[0].respond_to?(f) } return temp_hash elsif values[0].class == Hash return values[0].dup elsif values[0].kind_of?(Struct) temp_hash = {} @field_names[1..-1].collect { |f| temp_hash[f] = values[0][f] if values[0].members.include?( f.to_s) } return temp_hash elsif values[0].class == Array raise ArgumentError, "Must specify all fields in input " + \ "array." unless values[0].size == @field_names[1..-1].size temp_hash = {} @field_names[1..-1].collect { |f| temp_hash[f] = values[0][@field_names.index(f)-1] } return temp_hash elsif values.class == Array raise ArgumentError, "Must specify all fields in input " + \ "array." unless values.size == @field_names[1..-1].size temp_hash = {} @field_names[1..-1].collect { |f| temp_hash[f] = values[@field_names.index(f)-1] } return temp_hash else raise(ArgumentError, 'Invalid type for values container.') end end #----------------------------------------------------------------------- # validate_input #----------------------------------------------------------------------- #++ # Check input data to ensure proper data types. # # *data*:: Hash of data values. # def validate_input(data) raise "Cannot insert/update recno field!" if data.has_key?(:recno) @field_names[1..-1].each do |f| next unless data.has_key?(f) next if data[f].nil? case @field_types[@field_names.index(f)] when :String raise "Invalid String value for: %s" % f unless \ data[f].respond_to?(:to_str) when :Boolean raise "Invalid Boolean value for: %s" % f unless \ data[f].kind_of?(TrueClass) or data[f].kind_of?(FalseClass) when :Integer raise "Invalid Integer value for: %s" % f unless \ data[f].respond_to?(:to_int) when :Float raise "Invalid Float value for: %s" % f unless \ data[f].class == Float when :Date raise "Invalid Date value for: %s" % f unless \ data[f].class == Date when :DateTime raise "Invalid DateTime value for: %s" % f unless \ data[f].class == DateTime end end end #----------------------------------------------------------------------- # update_header_vars #----------------------------------------------------------------------- #++ # Read header record and update instance variables. # def update_header_vars @encrypted, @last_rec_no, @del_ctr, @record_class, @field_names, \ @field_types = @db.engine.get_header_vars(self) end #----------------------------------------------------------------------- # get_matches #----------------------------------------------------------------------- #++ # Return records from table that match select condition. # # *query_type*:: Symbol specifying type of query (:select, :update, # or :delete). # *filter*:: Array of field names to include in each record of result # set. # *select_cond*:: Proc containing select condition. # def get_matches(query_type, filter, select_cond) tbl_struct = Struct.new(*@field_names) case query_type when :select if @record_class == 'Struct' result_struct = Struct.new(*filter) end when :update result_struct = Struct.new(*(filter + [:fpos, :line_length])) when :delete result_struct = Struct.new(:fpos, :line_length) end # Create an empty array to hold any matches found. matchList = KBResultSet.new(self, filter, filter.collect { |f| @field_types[@field_names.index(f)] }) type_convs = @field_types.collect { |x| TYPE_CONV[x] } # Loop through table. @db.engine.get_recs(self).each do |rec| tbl_rec = tbl_struct.new(*(0...@field_names.size).collect do |i| if rec[i] == '' nil else send(type_convs[i], rec[i]) end end) next unless select_cond.call(tbl_rec) unless select_cond.nil? if query_type != :select or @record_class == 'Struct' result_rec = result_struct.new(*filter.collect { |f| tbl_rec.send(f) }) else if Object.const_get(@record_class).respond_to?(:kb_defaults) result_rec = Object.const_get(@record_class).new( *@field_names.collect { |f| tbl_rec.send(f) || Object.const_get(@record_class ).kb_defaults[@field_names.index(f)] } ) else result_rec = Object.const_get(@record_class).kb_create( *@field_names.collect { |f| # Just a warning here: If you specify a filter on # a select, you are only going to get those fields # you specified in the result set, EVEN IF # record_class is a custom class instead of Struct. if filter.include?(f) tbl_rec.send(f) else nil end } ) end end unless query_type == :select result_rec.fpos = rec[-2] result_rec.line_length = rec[-1] end matchList << result_rec end return matchList end #----------------------------------------------------------------------- # unencode_str #----------------------------------------------------------------------- #++ # Return string to unencoded format. # # *s*:: String to be unencoded. # def unencode_str(s) if s =~ UNENCODE_RE return s.gsub('&linefeed;', "\n").gsub( '&carriage_return;', "\r").gsub('&substitute;', "\032").gsub( '&pipe;', "|").gsub('&', "&") else return s end end end #--------------------------------------------------------------------------- # KBResult #--------------------------------------------------------------------------- class KBResultSet < Array #----------------------------------------------------------------------- # KBResultSet.reverse #----------------------------------------------------------------------- # def KBResultSet.reverse(sort_field) return [sort_field, :desc] end #----------------------------------------------------------------------- # initialize #----------------------------------------------------------------------- # def initialize(table, filter, filter_types, *args) @table = table @filter = filter @filter_types = filter_types super(*args) end #----------------------------------------------------------------------- # to_ary #----------------------------------------------------------------------- # def to_ary to_a end #----------------------------------------------------------------------- # set #----------------------------------------------------------------------- #++ # Update record(s) in table, return number of records updated. # def set(*updates, &update_cond) raise 'Cannot specify both a hash and a proc for method #set!' \ unless updates.empty? or update_cond.nil? raise 'Must specify update proc or hash for method #set!' if \ updates.empty? and update_cond.nil? if updates.empty? @table.set(self, update_cond) else @table.set(self, updates) end end #----------------------------------------------------------------------- # sort #----------------------------------------------------------------------- # def sort(*sort_fields) sort_fields_arrs = [] sort_fields.each do |f| if f.to_s[0..0] == '-' sort_fields_arrs << [f.to_s[1..-1].to_sym, :desc] elsif f.to_s[0..0] == '+' sort_fields_arrs << [f.to_s[1..-1].to_sym, :asc] else sort_fields_arrs << [f, :asc] end end sort_fields_arrs.each do |f| raise "Invalid sort field" unless @filter.include?(f[0]) end super() { |a,b| x = [] y = [] sort_fields_arrs.each do |s| if [:Integer, :Float].include?( @filter_types[@filter.index(s[0])]) a_value = a.send(s[0]) || 0 b_value = b.send(s[0]) || 0 else a_value = a.send(s[0]) b_value = b.send(s[0]) end if s[1] == :desc x << b_value y << a_value else x << a_value y << b_value end end x <=> y } end #----------------------------------------------------------------------- # to_report #----------------------------------------------------------------------- # def to_report(recs_per_page=0, print_rec_sep=false) result = collect { |r| @filter.collect {|f| r.send(f)} } # How many records before a formfeed. delim = ' | ' # columns of physical rows columns = [@filter].concat(result).transpose max_widths = columns.collect { |c| c.max { |a,b| a.to_s.length <=> b.to_s.length }.to_s.length } row_dashes = '-' * (max_widths.inject {|sum, n| sum + n} + delim.length * (max_widths.size - 1)) justify_hash = { :String => :ljust, :Integer => :rjust, :Float => :rjust, :Boolean => :ljust, :Date => :ljust, :DateTime => :ljust } header_line = @filter.zip(max_widths, @filter.collect { |f| @filter_types[@filter.index(f)] }).collect { |x,y,z| x.to_s.send(justify_hash[z], y) }.join(delim) output = '' recs_on_page_cnt = 0 result.each do |row| if recs_on_page_cnt == 0 output << header_line + "\n" << row_dashes + "\n" end output << row.zip(max_widths, @filter.collect { |f| @filter_types[@filter.index(f)] }).collect { |x,y,z| x.to_s.send(justify_hash[z], y) }.join(delim) + "\n" output << row_dashes + '\n' if print_rec_sep recs_on_page_cnt += 1 if recs_per_page > 0 and (recs_on_page_cnt == num_recs_per_page) output << '\f' recs_on_page_count = 0 end end return output end end #--------------------------------------------------------------------------- # NilClass #--------------------------------------------------------------------------- # class NilClass def method_missing(method_id, stuff) return false end end #--------------------------------------------------------------------------- # Symbol #--------------------------------------------------------------------------- # class Symbol def -@ ("-"+self.to_s).to_sym end def +@ ("+"+self.to_s).to_sym end end