lib/vendor/kirbybase.rb in og-0.23.0 vs lib/vendor/kirbybase.rb in og-0.24.0

- old
+ new

@@ -1,1601 +1,2790 @@ -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("&", '&amp;').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('&amp;', "&") - 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 - +require 'date' +require 'drb' +require 'csv' +require 'fileutils' +require 'yaml' + +# +# :main:KirbyBase +# :title:KirbyBase Class Documentation +# 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. +# 2005-08-09:: Version 2.2.1 +# * Fixed a bug in with_write_lock. +# * Fixed a bug that occurred if @record_class was a nested class. +# 2005-09-08:: Version 2.3 Beta 1 +# * Added ability to specify one-to-one links between tables. +# * Added ability to specify one-to-many links between tables. +# * Added ability to specify calculated fields in tables. +# * Added Memo and Blob field types. +# * Added indexing to speed up queries. +# 2005-10-03:: Version 2.3 Beta 2 +# * New column type: :YAML. Many thanks to Logan Capaldo for this idea! +# * Two new methods: #add_table_column and #drop_table_column. +# * I have refined the select code so that, when you are doing a one-to-one +# or one-to-many select, if an appropriate index exists for the child +# table, KirbyBase automatically uses it. +# * I have changed the designation for a one-to-one link from Link-> to +# Lookup-> after googling helped me see that this is a more correct term +# for what I am trying to convey with this link type. +# 2005-10-10:: Version 2.3 Production +# * Added the ability to designate a table field as the "key" field, for +# Lookup purposes. This simply makes it easier to define Lookup fields. +# * This led me to finally give in and add "the Hal Fulton Feature" as I am +# forever going to designate it. You can now specify a Lookup field +# simply by specifying it's field type as a table, for example: +# :manager, :person (where :manager is the field name, and :person is the +# name of a table). See the docs for the specifics or ask Hal. :) +# +#--------------------------------------------------------------------------- +# 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? + + @table_hash = {} + + # 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 + + # The reason why I create all the table instances here is two + # reasons: (1) I want all of the tables ready to go when a user + # does a #get_table, so they don't have to wait for the instance + # to be created, and (2) I want all of the table indexes to get + # created at the beginning during database initialization so that + # they are ready for the user to use. Since index creation + # happens when the table instance is first created, I go ahead and + # create table instances right off the bat. + # + # Also, I use to only execute the code below if this was either a + # single-user instance of KirbyBase or if client-server, I would + # only let the client-side KirbyBase instance create the table + # instances, since there was no need for the server-side KirbyBase + # instance to create table instances. But, since I want indexes + # created at db initialization and the server's db instance might + # be initialized long before any client's db is initialized, I now + # let the server create table instances also. This is strictly to + # get the indexes created, there is no other use for the table + # instances on the server side as they will never be used. + # Everything should and does go through the table instances created + # on the client-side. + @engine.tables.each do |tbl| + @table_hash[tbl] = KBTable.create_called_from_database_instance( + self, tbl, File.join(@path, tbl.to_s + @ext)) + 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 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) + + if @table_hash.has_key?(name) + return @table_hash[name] + else + @table_hash[name] = \ + KBTable.create_called_from_database_instance(self, + name, File.join(@path, name.to_s + @ext)) + return @table_hash[name] + end + end + + #----------------------------------------------------------------------- + # create_table + #----------------------------------------------------------------------- + #++ + # Create new table and return a reference to the new table. + # *name*:: Symbol of table name. + # *field_defs*:: List of field names (Symbols), field types (Symbols), + # field indexes, and field extras (Indexes, Lookups, + # Link_manys, Calculateds, etc.) + # *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 "Name must be a symbol!" unless t.name.is_a?(Symbol) + 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 of table name. + # + def drop_table(tablename) + raise "Table does not exist!" unless table_exists?(tablename) + @table_hash.delete(tablename) + return @engine.delete_table(tablename) + end + + #----------------------------------------------------------------------- + # table_exists? + #----------------------------------------------------------------------- + #++ + # Return true if table exists. + # + # *tablename*:: Symbol of table name. + # + def table_exists?(tablename) + return @engine.table_exists?(tablename) + end + + #----------------------------------------------------------------------- + # add_table_column + #----------------------------------------------------------------------- + #++ + # Add a column to a table. + # + # Make sure you are executing this method while in single-user mode + # (i.e. not running in client/server mode). After you run it, it is + # probably a good idea to release your handle on the db and + # re-initialize KirbyBase, as this method changes the table structure. + # + # *tablename*:: Symbol of table name. + # *col_name*:: Symbol of column name to add. + # *col_type*:: Symbol (or Hash if includes field extras) of column type + # to add. + # *after*:: Symbol of column name that you want to add this column + # after. + # + def add_table_column(tablename, col_name, col_type, after=nil) + raise "Do not execute this method from the server!!!" if server? + + raise "Invalid table name!" unless table_exists?(tablename) + + raise "Invalid field name in 'after': #{after}" unless after.nil? \ + or @table_hash[tablename].field_names.include?(after) + + # Does this new column have field extras (i.e. Index, Lookup, etc.) + if col_type.is_a?(Hash) + temp_type = col_type[:DataType] + else + temp_type = col_type + end + + raise 'Invalid field type: %s' % temp_type unless \ + KBTable.valid_field_type?(temp_type) + + @engine.add_column(@table_hash[tablename], col_name, col_type, + after) + + # Need to reinitialize the table instance and associated indexes. + @engine.remove_recno_index(tablename) + @engine.remove_indexes(tablename) + @table_hash.delete(tablename) + @table_hash[tablename] = \ + KBTable.create_called_from_database_instance(self, tablename, + File.join(@path, tablename.to_s + @ext)) + end + + #----------------------------------------------------------------------- + # drop_table_column + #----------------------------------------------------------------------- + #++ + # Drop a column from a table. + # + # Make sure you are executing this method while in single-user mode + # (i.e. not running in client/server mode). After you run it, it is + # probably a good idea to release your handle on the db and + # re-initialize KirbyBase, as this method changes the table structure. + # + # *tablename*:: Symbol of table name. + # *col_name*:: Symbol of column name to add. + # + def drop_table_column(tablename, col_name) + raise "Do not execute this method from the server!!!" if server? + + raise "Invalid table name!" unless table_exists?(tablename) + + raise 'Invalid column name: ' % col_name unless \ + @table_hash[tablename].field_names.include?(col_name) + + raise "Cannot drop :recno column!" if col_name == :recno + + @engine.drop_column(@table_hash[tablename], col_name) + + # Need to reinitialize the table instance and associated indexes. + @engine.remove_recno_index(tablename) + @engine.remove_indexes(tablename) + @table_hash.delete(tablename) + @table_hash[tablename] = \ + KBTable.create_called_from_database_instance(self, tablename, + File.join(@path, tablename.to_s + @ext)) + 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 + + # Make constructor private. + private_class_method :new + + #----------------------------------------------------------------------- + # KBEngine.create_called_from_database_instance + #----------------------------------------------------------------------- + def KBEngine.create_called_from_database_instance(db) + return new(db) + end + + #----------------------------------------------------------------------- + # initialize + #----------------------------------------------------------------------- + def initialize(db) + @db = db + @recno_indexes = {} + @indexes = {} + + # This hash will hold the table locks if in client/server mode. + @mutex_hash = {} if @db.server? + end + + #----------------------------------------------------------------------- + # init_recno_index + #----------------------------------------------------------------------- + def init_recno_index(table) + return if recno_index_exists?(table) + + with_write_locked_table(table) do |fptr| + @recno_indexes[table.name] = KBRecnoIndex.new(table) + @recno_indexes[table.name].rebuild(fptr) + end + end + + #----------------------------------------------------------------------- + # rebuild_recno_index + #----------------------------------------------------------------------- + def rebuild_recno_index(table) + with_write_locked_table(table) do |fptr| + @recno_indexes[table.name].rebuild(fptr) + end + end + + #----------------------------------------------------------------------- + # remove_recno_index + #----------------------------------------------------------------------- + def remove_recno_index(tablename) + @recno_indexes.delete(tablename) + end + + #----------------------------------------------------------------------- + # recno_index_exists? + #----------------------------------------------------------------------- + def recno_index_exists?(table) + @recno_indexes.include?(table.name) + end + + #----------------------------------------------------------------------- + # init_index + #----------------------------------------------------------------------- + def init_index(table, index_fields) + return if index_exists?(table, index_fields) + + with_write_locked_table(table) do |fptr| + @indexes["#{table.name}_#{index_fields.join('_')}".to_sym] = \ + KBIndex.new(table, index_fields) + @indexes["#{table.name}_#{index_fields.join('_')}".to_sym + ].rebuild(fptr) + end + end + + #----------------------------------------------------------------------- + # rebuild_index + #----------------------------------------------------------------------- + def rebuild_index(table, index_fields) + with_write_locked_table(table) do |fptr| + @indexes["#{table.name}_#{index_fields.join('_')}".to_sym + ].rebuild(fptr) + end + end + + #----------------------------------------------------------------------- + # remove_indexes + #----------------------------------------------------------------------- + def remove_indexes(tablename) + re_table_name = Regexp.new(tablename.to_s) + @indexes.delete_if { |k,v| k.to_s =~ re_table_name } + end + + #----------------------------------------------------------------------- + # add_to_indexes + #----------------------------------------------------------------------- + def add_to_indexes(table, rec, fpos) + @recno_indexes[table.name].add_index_rec(rec.first, fpos) + + re_table_name = Regexp.new(table.name.to_s) + @indexes.each_pair do |key, index| + index.add_index_rec(rec) if key.to_s =~ re_table_name + end + end + + #----------------------------------------------------------------------- + # delete_from_indexes + #----------------------------------------------------------------------- + def delete_from_indexes(table, rec, fpos) + @recno_indexes[table.name].delete_index_rec(rec.recno) + + re_table_name = Regexp.new(table.name.to_s) + @indexes.each_pair do |key, index| + index.delete_index_rec(rec.recno) if key.to_s =~ re_table_name + end + end + + #----------------------------------------------------------------------- + # index_exists? + #----------------------------------------------------------------------- + def index_exists?(table, index_fields) + @indexes.include?("#{table.name}_#{index_fields.join('_')}".to_sym) + end + + #----------------------------------------------------------------------- + # update_recno_index + #----------------------------------------------------------------------- + def update_recno_index(table, recno, fpos) + @recno_indexes[table.name].update_index_rec(recno, fpos) + end + + #----------------------------------------------------------------------- + # update_to_indexes + #----------------------------------------------------------------------- + def update_to_indexes(table, rec) + re_table_name = Regexp.new(table.name.to_s) + @indexes.each_pair do |key, index| + index.update_index_rec(rec) if key.to_s =~ re_table_name + end + end + + #----------------------------------------------------------------------- + # get_index + #----------------------------------------------------------------------- + def get_index(table, index_name) + return @indexes["#{table.name}_#{index_name}".to_sym].get_idx + end + + #----------------------------------------------------------------------- + # get_recno_index + #----------------------------------------------------------------------- + def get_recno_index(table) + return @recno_indexes[table.name].get_idx + end + + #----------------------------------------------------------------------- + # table_exists? + #----------------------------------------------------------------------- + def table_exists?(tablename) + return File.exists?(File.join(@db.path, tablename.to_s + @db.ext)) + end + + #----------------------------------------------------------------------- + # tables + #----------------------------------------------------------------------- + def tables + list = [] + Dir.foreach(@db.path) { |filename| + list << File.basename(filename, '.*').to_sym if \ + File.extname(filename) == @db.ext + } + return list + end + + #----------------------------------------------------------------------- + # build_header_field_string + #----------------------------------------------------------------------- + def build_header_field_string(field_name_def, field_type_def) + # Put field name at start of string definition. + temp_field_def = field_name_def.to_s + ':' + + # if field type is a hash, that means that it is not just a + # simple field. Either is is being used in an index, it is a + # Lookup field, it is a Link_many field, or it is a Calculated + # field. This next bit of code is to piece together a proper + # string so that it can be written out to the header rec. + if field_type_def.is_a?(Hash) + raise 'Missing :DataType key in field type hash!' unless \ + field_type_def.has_key?(:DataType) + + temp_type = field_type_def[:DataType] + + raise 'Invalid field type: %s' % temp_type unless \ + KBTable.valid_field_type?(temp_type) + + temp_field_def += field_type_def[:DataType].to_s + + if field_type_def.has_key?(:Key) + temp_field_def += ':Key->true' + end + if field_type_def.has_key?(:Index) + raise 'Invalid field type for index: %s' % temp_type \ + unless KBTable.valid_index_type?(temp_type) + + temp_field_def += ':Index->' + field_type_def[:Index].to_s + end + if field_type_def.has_key?(:Lookup) + if field_type_def[:Lookup].is_a?(Array) + temp_field_def += \ + ':Lookup->%s.%s' % field_type_def[:Lookup] + else + tbl = @db.get_table(field_type_def[:Lookup]) + temp_field_def += \ + ':Lookup->%s.%s' % [field_type_def[:Lookup], + tbl.lookup_key] + end + elsif field_type_def.has_key?(:Link_many) + raise 'Field type for Link_many field must be :ResultSet' \ + unless temp_type == :ResultSet + temp_field_def += \ + ':Link_many->%s=%s.%s' % field_type_def[:Link_many] + elsif field_type_def.has_key?(:Calculated) + temp_field_def += \ + ':Calculated->%s' % field_type_def[:Calculated] + end + else + if KBTable.valid_field_type?(field_type_def) + temp_field_def += field_type_def.to_s + elsif @db.table_exists?(field_type_def) + tbl = @db.get_table(field_type_def) + temp_field_def += \ + '%s:Lookup->%s.%s' % [tbl.field_types[ + tbl.field_names.index(tbl.lookup_key)], field_type_def, + tbl.lookup_key] + else + raise 'Invalid field type: %s' % field_type_def + end + end + return temp_field_def + end + + #----------------------------------------------------------------------- + # new_table + #----------------------------------------------------------------------- + #++ + # Create physical file holding table. This table should not be directly + # called in your application, but only called by #create_table. + # + def new_table(name, field_defs, encrypt, record_class) + # Can't create a table that already exists! + raise "Table 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) do |x| + temp_field_defs << build_header_field_string(field_defs[x], + field_defs[x+1]) + end + + # 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 + #----------------------------------------------------------------------- + def delete_table(tablename) + with_write_lock(tablename) do + remove_indexes(tablename) + remove_recno_index(tablename) + File.delete(File.join(@db.path, tablename.to_s + @db.ext)) + return true + end + end + + #---------------------------------------------------------------------- + # get_total_recs + #---------------------------------------------------------------------- + def get_total_recs(table) + return get_recs(table).size + end + + #----------------------------------------------------------------------- + # get_header_vars + #----------------------------------------------------------------------- + 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 } + field_indexes = [nil] * field_names.size + field_extras = [nil] * field_names.size + + flds.each_with_index do |x,i| + field_extras[i] = {} + if x.split(':').size > 2 + x.split(':')[2..-1].each do |y| + if y =~ /Index/ + field_indexes[i] = y + else + field_extras[i][y.split('->')[0]] = \ + y.split('->')[1] + end + end + end + end + return [table.encrypted?, last_rec_no.to_i, del_ctr.to_i, + record_class, field_names, field_types, field_indexes, + field_extras] + end + end + + #----------------------------------------------------------------------- + # get_recs + #----------------------------------------------------------------------- + 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.chomp! + line_length = line.length + + 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 + + #----------------------------------------------------------------------- + # get_recs_by_recno + #----------------------------------------------------------------------- + def get_recs_by_recno(table, recnos) + encrypted = table.encrypted? + recs = [] + recno_idx = get_recno_index(table) + + with_table(table) do |fptr| + # Skip header rec. + fptr.readline + + recnos.collect { |r| [recno_idx[r], r] + }.sort.each do |r| + fptr.seek(r[0]) + line = fptr.readline + line.chomp! + line_length = line.length + + 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) + raise "Index Corrupt!" unless rec[0].to_i == r[1] + rec << r[0] << line_length + recs << rec + end + return recs + end + end + + #----------------------------------------------------------------------- + # get_rec_by_recno + #----------------------------------------------------------------------- + def get_rec_by_recno(table, recno) + encrypted = table.encrypted? + recno_idx = get_recno_index(table) + + return nil unless recno_idx.has_key?(recno) + + with_table(table) do |fptr| + fptr.seek(recno_idx[recno]) + line = fptr.readline + line.chomp! + line_length = line.length + + line = unencrypt_str(line) if encrypted + line.strip! + + return nil if line == '' + + # Split the line up into fields. + rec = line.split('|', -1) + + raise "Index Corrupt!" unless rec[0].to_i == recno + rec << recno_idx[recno] << line_length + return rec + 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 + + fptr.seek(0, IO::SEEK_END) + fpos = fptr.tell + + write_record(table, fptr, 'end', rec.join('|')) + + add_to_indexes(table, rec, fpos) + + # 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].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] + fptr.seek(0, IO::SEEK_END) + new_fpos = fptr.tell + write_record(table, fptr, 'end', line) + incr_del_ctr(table, fptr) + + update_recno_index(table, rec[:rec].first, new_fpos) + else + write_record(table, fptr, rec[:fpos], line) + end + update_to_indexes(table, rec[:rec]) + 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) + + delete_from_indexes(table, rec, rec.fpos) + end + + # Return the number of records deleted. + return recs.size + end + end + + #----------------------------------------------------------------------- + # add_column + #----------------------------------------------------------------------- + def add_column(table, col_name, col_type, after) + temp_field_def = build_header_field_string(col_name, col_type) + + if after.nil? + insert_after = -1 + else + if table.field_names.last == after + insert_after = -1 + else + insert_after = table.field_names.index(after)+1 + end + end + + with_write_lock(table.name) do + fptr = open(table.filename, 'r') + new_fptr = open(table.filename+'temp', 'w') + + line = fptr.readline.chomp + + if line[0..0] == 'Z' + header_rec = unencrypt_str(line[1..-1]).split('|') + if insert_after == -1 + header_rec.insert(insert_after, temp_field_def) + else + header_rec.insert(insert_after+3, temp_field_def) + end + new_fptr.write('Z' + encrypt_str(header_rec.join('|')) + + "\n") + else + header_rec = line.split('|') + if insert_after == -1 + header_rec.insert(insert_after, temp_field_def) + else + header_rec.insert(insert_after+3, temp_field_def) + end + new_fptr.write(header_rec.join('|') + "\n") + end + + begin + while true + line = fptr.readline.chomp + + if table.encrypted? + temp_line = unencrypt_str(line) + else + temp_line = line + end + + rec = temp_line.split('|') + rec.insert(insert_after, '') + + if table.encrypted? + new_fptr.write(encrypt_str(rec.join('|')) + "\n") + else + new_fptr.write(rec.join('|') + "\n") + 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) + end + end + + #----------------------------------------------------------------------- + # drop_column + #----------------------------------------------------------------------- + def drop_column(table, col_name) + col_index = table.field_names.index(col_name) + with_write_lock(table.name) do + fptr = open(table.filename, 'r') + new_fptr = open(table.filename+'temp', 'w') + + line = fptr.readline.chomp + + if line[0..0] == 'Z' + header_rec = unencrypt_str(line[1..-1]).split('|') + header_rec.delete_at(col_index+3) + new_fptr.write('Z' + encrypt_str(header_rec.join('|')) + + "\n") + else + header_rec = line.split('|') + header_rec.delete_at(col_index+3) + new_fptr.write(header_rec.join('|') + "\n") + end + + begin + while true + line = fptr.readline.chomp + + if table.encrypted? + temp_line = unencrypt_str(line) + else + temp_line = line + end + + rec = temp_line.split('|') + rec.delete_at(col_index) + + if table.encrypted? + new_fptr.write(encrypt_str(rec.join('|')) + "\n") + else + new_fptr.write(rec.join('|') + "\n") + 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) + end + end + + #----------------------------------------------------------------------- + # pack_table + #----------------------------------------------------------------------- + def pack_table(table) + with_write_lock(table.name) 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 + + #----------------------------------------------------------------------- + # get_memo + #----------------------------------------------------------------------- + def get_memo(filepath) + begin + f = File.new(filepath) + return f.readlines + ensure + f.close + end + end + + #----------------------------------------------------------------------- + # get_blob + #----------------------------------------------------------------------- + def get_blob(filepath) + begin + f = File.new(filepath, 'rb') + return f.read + ensure + f.close + 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(tablename) + begin + write_lock(tablename) if @db.server? + yield + ensure + write_unlock(tablename) 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 + #----------------------------------------------------------------------- + def encrypt_str(s) + # Returns an encrypted string, using the Vignere Cipher. + + 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 + #----------------------------------------------------------------------- + def unencrypt_str(s) + # Returns an unencrypted string, using the Vignere Cipher. + + 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 +end + + +#--------------------------------------------------------------------------- +# KBTypeConversionsMixin +#--------------------------------------------------------------------------- +module KBTypeConversionsMixin + UNENCODE_RE = /&(?:amp|linefeed|carriage_return|substitute|pipe);/ + + #----------------------------------------------------------------------- + # convert_to + #----------------------------------------------------------------------- + def convert_to(data_type, s) + return nil if s.empty? or s.nil? + + case data_type + when :String + if s =~ UNENCODE_RE + return s.gsub('&linefeed;', "\n").gsub('&carriage_return;', + "\r").gsub('&substitute;', "\032").gsub('&pipe;', "|" + ).gsub('&amp;', "&") + else + return s + end + when :Integer + return s.to_i + when :Float + return s.to_f + when :Boolean + if ['false', 'False', nil, false].include?(s) + return false + else + return true + end + when :Date + return Date.parse(s) + when :Time + return Time.parse(s) + when :DateTime + return DateTime.parse(s) + when :YAML + # This code is here in case the YAML field is the last + # field in the record. Because YAML normall defines a + # nil value as "--- ", but KirbyBase strips trailing + # spaces off the end of the record, so if this is the + # last field in the record, KirbyBase will strip the + # trailing space off and make it "---". When KirbyBase + # attempts to convert this value back using to_yaml, + # you get an exception. + if s == "---" + return nil + elsif s =~ UNENCODE_RE + y = s.gsub('&linefeed;', "\n").gsub('&carriage_return;', + "\r").gsub('&substitute;', "\032").gsub('&pipe;', "|" + ).gsub('&amp;', "&") + return YAML.load(y) + else + return YAML.load(s) + end + when :Memo + return KBMemo.new(@tbl.db, s) + when :Blob + return KBBlob.new(@tbl.db, s) + else + raise "Invalid field type: %s" % data_type + 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 + + VALID_FIELD_TYPES = [:String, :Integer, :Float, :Boolean, :Date, :Time, + :DateTime, :Memo, :ResultSet, :YAML] + + VALID_INDEX_TYPES = [:String, :Integer, :Float, :Boolean, :Date, :Time, + :DateTime] + + # Regular expression used to determine if field needs to be + # encoded. + ENCODE_RE = /&|\n|\r|\032|\|/ + + attr_reader :filename, :name, :table_class, :db, :lookup_key + + #----------------------------------------------------------------------- + # KBTable.valid_field_type + #----------------------------------------------------------------------- + #++ + # Return true if valid field type. + # + # *field_type*:: Symbol specifying field type. + # + def KBTable.valid_field_type?(field_type) + VALID_FIELD_TYPES.include?(field_type) + end + + #----------------------------------------------------------------------- + # KBTable.valid_index_type + #----------------------------------------------------------------------- + #++ + # Return true if valid index type. + # + # *field_type*:: Symbol specifying field type. + # + def KBTable.valid_index_type?(field_type) + VALID_INDEX_TYPES.include?(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. + # + def KBTable.create_called_from_database_instance(db, name, filename) + return new(db, name, filename) + end + + #----------------------------------------------------------------------- + # initialize + #----------------------------------------------------------------------- + #++ + # 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 + @lookup_key = :recno + + # Alias delete_all to clear method. + alias delete_all clear + + update_header_vars + create_indexes + create_table_class unless @db.server? + 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 + return @field_names + end + + #----------------------------------------------------------------------- + # field_types + #----------------------------------------------------------------------- + #++ + # Return array containing table field types. + # + def field_types + return @field_types + end + + #----------------------------------------------------------------------- + # field_extras + #----------------------------------------------------------------------- + #++ + # Return array containing table field extras. + # + def field_extras + return @field_extras + end + + #----------------------------------------------------------------------- + # field_indexes + #----------------------------------------------------------------------- + #++ + # Return array containing table field indexes. + # + def field_indexes + return @field_indexes + 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.zip(@field_types + ).collect do |fn, ft| + convert_to_string(ft, input_rec.fetch(fn, '')) + end) + 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) + # 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) + + # Make sure all of the fields of the update rec are of the proper + # type. + validate_input(update_rec) + + updated_recs = [] + + # For each one of the recs that matched the update query, apply the + # updates to it and write it back to the database table. + recs.each do |rec| + updated_rec = {} + updated_rec[:rec] = \ + @field_names.zip(@field_types).collect do |fn, ft| + convert_to_string(ft, update_rec.fetch(fn, rec.send(fn))) + 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? + + # Get all records that match the selection criteria and + # return them in an array. + result_set = get_matches(:delete, [:recno], 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) + return nil if index[0].nil? + + return get_match_by_recno(:select, @field_names, index[0]) if \ + index.size == 1 + + recs = select_by_recno_index(*@field_names) { |r| + index.includes?(r.recno) + } + + return recs + 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 = [] + + # 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 + + #----------------------------------------------------------------------- + # select_by_recno_index + #----------------------------------------------------------------------- + #++ + # Return array of records (Structs) matching select conditions. Select + # condition block should not contain references to any table column + # except :recno. If you need to select by other table columns than just + # :recno, use #select instead. + # + # *filter*:: List of field names (Symbols) to include in result set. + # *select_cond*:: Proc containing select code. + # + def select_by_recno_index(*filter, &select_cond) + # Declare these variables before the code block so they don't go + # after the code block is done. + result_set = [] + + # 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_by_recno_index(:select, filter, select_cond) + end + + #----------------------------------------------------------------------- + # pack + #----------------------------------------------------------------------- + #++ + # Remove blank records from table, return total removed. + # + def pack + lines_deleted = @db.engine.pack_table(self) + + update_header_vars + + @db.engine.remove_recno_index(@name) + @db.engine.remove_indexes(@name) + + create_indexes + create_table_class unless @db.server? + + return lines_deleted + 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) + tbl_rec = @table_class.new(self) + + CSV.open(csv_filename, 'r') do |row| + tbl_rec.populate([nil] + row) + insert(tbl_rec) + end + end + + #----------------------------------------------------------------------- + # PRIVATE METHODS + #----------------------------------------------------------------------- + private + + #----------------------------------------------------------------------- + # create_indexes + #----------------------------------------------------------------------- + def create_indexes + # Create the recno index. A recno index always gets created even if + # there are no user-defined indexes for the table. + @db.engine.init_recno_index(self) + + # There can be up to 5 different indexes on a table. Any of these + # indexes can be single or compound. + ['Index->1', 'Index->2', 'Index->3', 'Index->4', + 'Index->5'].each do |idx| + index_col_names = [] + @field_indexes.each_with_index do |fi,i| + next if fi.nil? + index_col_names << @field_names[i] if fi.include?(idx) + end + + # If no fields were indexed on this number (1..5), go to the + # next index number. + next if index_col_names.empty? + + # Create this index on the engine. + @db.engine.init_index(self, index_col_names) + + # For each index found, add an instance method for it so that + # it can be used for #selects. + select_meth_str = <<-END_OF_STRING + def select_by_#{index_col_names.join('_')}_index(*filter, + &select_cond) + result_set = [] + validate_filter(filter) + filter = @field_names if filter.empty? + return get_matches_by_index(:select, + [:#{index_col_names.join(',:')}], filter, select_cond) + end + END_OF_STRING + self.class.class_eval(select_meth_str) + end + end + + #----------------------------------------------------------------------- + # create_table_class + #----------------------------------------------------------------------- + def create_table_class + #This is the class that will be used in #select condition blocks. + @table_class = Class.new(KBTableRec) + + get_meth_str = '' + get_meth_upd_res_str = '' + set_meth_str = '' + + @field_names.zip(@field_types, @field_extras) do |x| + field_name, field_type, field_extra = x + + @lookup_key = field_name if field_extra.has_key?('Key') + + # These are the default get/set methods for the table column. + get_meth_str = <<-END_OF_STRING + def #{field_name} + return @#{field_name} + end + END_OF_STRING + get_meth_upd_res_str = <<-END_OF_STRING + def #{field_name}_upd_res + return @#{field_name} + end + END_OF_STRING + set_meth_str = <<-END_OF_STRING + def #{field_name}=(s) + @#{field_name} = convert_to(:#{field_type}, s) + end + END_OF_STRING + + # If this is a Lookup field, modify the get_method. + if field_extra.has_key?('Lookup') + lookup_table, key_field = field_extra['Lookup'].split('.') + if key_field == 'recno' + get_meth_str = <<-END_OF_STRING + def #{field_name} + table = @tbl.db.get_table(:#{lookup_table}) + return table[@#{field_name}] + end + END_OF_STRING + else + begin + @db.get_table(lookup_table) + rescue RuntimeError + raise "Must create child table first when using " + + "'Lookup'" + end + + if @db.get_table(lookup_table).respond_to?( + 'select_by_%s_index' % key_field) + get_meth_str = <<-END_OF_STRING + def #{field_name} + table = @tbl.db.get_table(:#{lookup_table}) + return table.select_by_#{key_field}_index { |r| + r.#{key_field} == @#{field_name} }.first + end + END_OF_STRING + else + get_meth_str = <<-END_OF_STRING + def #{field_name} + table = @tbl.db.get_table(:#{lookup_table}) + return table.select { |r| + r.#{key_field} == @#{field_name} }.first + end + END_OF_STRING + end + end + end + + # If this is a Link_many field, modify the get/set methods. + if field_extra.has_key?('Link_many') + lookup_field, rest = field_extra['Link_many'].split('=') + link_table, link_field = rest.split('.') + + begin + @db.get_table(link_table) + rescue RuntimeError + raise "Must create child table first when using " + + "'Link_many'" + end + + if @db.get_table(link_table).respond_to?( + 'select_by_%s_index' % link_field) + get_meth_str = <<-END_OF_STRING + def #{field_name} + table = @tbl.db.get_table(:#{link_table}) + return table.select_by_#{link_field}_index { |r| + r.send(:#{link_field}) == @#{lookup_field} } + end + END_OF_STRING + else + get_meth_str = <<-END_OF_STRING + def #{field_name} + table = @tbl.db.get_table(:#{link_table}) + return table.select { |r| + r.send(:#{link_field}) == @#{lookup_field} } + end + END_OF_STRING + end + + get_meth_upd_res_str = <<-END_OF_STRING + def #{field_name}_upd_res + return nil + end + END_OF_STRING + set_meth_str = <<-END_OF_STRING + def #{field_name}=(s) + @#{field_name} = nil + end + END_OF_STRING + end + + # If this is a Calculated field, modify the get/set methods. + if field_extra.has_key?('Calculated') + calculation = field_extra['Calculated'] + + get_meth_str = <<-END_OF_STRING + def #{field_name}() + return #{calculation} + end + END_OF_STRING + get_meth_upd_res_str = <<-END_OF_STRING + def #{field_name}_upd_res() + return nil + end + END_OF_STRING + set_meth_str = <<-END_OF_STRING + def #{field_name}=(s) + @#{field_name} = nil + end + END_OF_STRING + end + + @table_class.class_eval(get_meth_str) + @table_class.class_eval(get_meth_upd_res_str) + @table_class.class_eval(set_meth_str) + end + end + + #----------------------------------------------------------------------- + # convert_to_string + #----------------------------------------------------------------------- + def convert_to_string(data_type, x) + case data_type + when :YAML + y = x.to_yaml + if y =~ ENCODE_RE + return y.gsub("&", '&amp;').gsub("\n", '&linefeed;').gsub( + "\r", '&carriage_return;').gsub("\032", '&substitute;' + ).gsub("|", '&pipe;') + else + return y + end + when :String + if x =~ ENCODE_RE + return x.gsub("&", '&amp;').gsub("\n", '&linefeed;').gsub( + "\r", '&carriage_return;').gsub("\032", '&substitute;' + ).gsub("|", '&pipe;') + else + return x + end + else + return x.to_s + end + end + + #----------------------------------------------------------------------- + # validate_filter + #----------------------------------------------------------------------- + #++ + # Check that filter contains valid field names. + # + def validate_filter(filter) + # Each field in the filter array must be a valid fieldname in the + # table. + filter.each { |f| + raise 'Invalid field name: %s in filter!' % f unless \ + @field_names.include?(f) + } + end + + #----------------------------------------------------------------------- + # convert_input_data + #----------------------------------------------------------------------- + #++ + # Convert data passed to #input, #update, or #set to a common format. + # + 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 or \ + values[0].class == @table_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. + # + 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|:Memo|:Blob/ + 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].is_a?(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].respond_to?(:to_f) + when :Date + raise 'Invalid Date value for: %s' % f unless \ + data[f].is_a?(Date) + when :Time + raise 'Invalid Time value for: %s' % f unless \ + data[f].is_a?(Time) + when :DateTime + raise 'Invalid DateTime value for: %s' % f unless \ + data[f].is_a?(DateTime) + when :YAML + raise 'Invalid YAML value for: %s' % f unless \ + data[f].respond_to?(:to_yaml) + 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, @field_indexes, @field_extras = \ + @db.engine.get_header_vars(self) + end + + #----------------------------------------------------------------------- + # get_result_struct + #----------------------------------------------------------------------- + def get_result_struct(query_type, filter) + case query_type + when :select + return Struct.new(*filter) if @record_class == 'Struct' + when :update + return Struct.new(*(filter + [:fpos, :line_length])) + when :delete + return Struct.new(:recno, :fpos, :line_length) + end + return nil + end + + #----------------------------------------------------------------------- + # create_result_rec + #----------------------------------------------------------------------- + def create_result_rec(query_type, filter, result_struct, tbl_rec, rec) + # If this isn't a select query or if it is a select query, but + # the table record class is simply a Struct, then we will use + # a Struct for the result record type. + if query_type != :select + result_rec = result_struct.new(*filter.collect { |f| + tbl_rec.send("#{f}_upd_res".to_sym) }) + elsif @record_class == 'Struct' + result_rec = result_struct.new(*filter.collect { |f| + tbl_rec.send(f) }) + else + if Object.full_const_get(@record_class).respond_to?(:kb_create) + result_rec = Object.full_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 + }) + elsif Object.full_const_get(@record_class).respond_to?( + :kb_defaults) + result_rec = Object.full_const_get(@record_class).new( + *@field_names.collect { |f| + tbl_rec.send(f) || Object.full_const_get( + @record_class).kb_defaults[@field_names.index(f)] + } + ) + end + end + + unless query_type == :select + result_rec.fpos = rec[-2] + result_rec.line_length = rec[-1] + end + return result_rec + end + + #----------------------------------------------------------------------- + # get_matches + #----------------------------------------------------------------------- + #++ + # Return records from table that match select condition. + # + def get_matches(query_type, filter, select_cond) + result_struct = get_result_struct(query_type, filter) + match_array = KBResultSet.new(self, filter, filter.collect { |f| + @field_types[@field_names.index(f)] }) + + tbl_rec = @table_class.new(self) + + # Loop through table. + @db.engine.get_recs(self).each do |rec| + tbl_rec.populate(rec) + next unless select_cond.call(tbl_rec) unless select_cond.nil? + + match_array << create_result_rec(query_type, filter, + result_struct, tbl_rec, rec) + + end + return match_array + end + + #----------------------------------------------------------------------- + # get_matches_by_index + #----------------------------------------------------------------------- + #++ + # Return records from table that match select condition using one of + # the table's indexes instead of searching the whole file. + # + def get_matches_by_index(query_type, index_fields, filter, select_cond) + good_matches = [] + + idx_struct = Struct.new(*(index_fields + [:recno])) + + begin + @db.engine.get_index(self, index_fields.join('_')).each do |rec| + good_matches << rec[-1] if select_cond.call( + idx_struct.new(*rec)) + end + rescue NoMethodError + raise 'Field name in select block not part of index!' + end + + return get_matches_by_recno(query_type, filter, good_matches) + end + + #----------------------------------------------------------------------- + # get_matches_by_recno_index + #----------------------------------------------------------------------- + #++ + # Return records from table that match select condition using the + # table's recno index instead of searching the whole file. + # + def get_matches_by_recno_index(query_type, filter, select_cond) + good_matches = [] + + idx_struct = Struct.new(:recno) + + begin + @db.engine.get_recno_index(self).each_key do |key| + good_matches << key if select_cond.call( + idx_struct.new(key)) + end + rescue NoMethodError + raise "Field name in select block not part of index!" + end + + return nil if good_matches.empty? + return get_matches_by_recno(query_type, filter, good_matches) + end + + #----------------------------------------------------------------------- + # get_match_by_recno + #----------------------------------------------------------------------- + #++ + # Return record from table that matches supplied recno. + # + def get_match_by_recno(query_type, filter, recno) + result_struct = get_result_struct(query_type, filter) + match_array = KBResultSet.new(self, filter, filter.collect { |f| + @field_types[@field_names.index(f)] }) + + tbl_rec = @table_class.new(self) + + rec = @db.engine.get_rec_by_recno(self, recno) + return nil if rec.nil? + tbl_rec.populate(rec) + + return create_result_rec(query_type, filter, result_struct, + tbl_rec, rec) + end + + #----------------------------------------------------------------------- + # get_matches_by_recno + #----------------------------------------------------------------------- + #++ + # Return records from table that match select condition. + # + def get_matches_by_recno(query_type, filter, recnos) + result_struct = get_result_struct(query_type, filter) + match_array = KBResultSet.new(self, filter, filter.collect { |f| + @field_types[@field_names.index(f)] }) + + tbl_rec = @table_class.new(self) + + @db.engine.get_recs_by_recno(self, recnos).each do |rec| + next if rec.nil? + tbl_rec.populate(rec) + + match_array << create_result_rec(query_type, filter, + result_struct, tbl_rec, rec) + end + return match_array + end +end + + +#--------------------------------------------------------------------------- +# KBMemo +#--------------------------------------------------------------------------- +class KBMemo + attr_reader :filepath, :memo + + #----------------------------------------------------------------------- + # initialize + #----------------------------------------------------------------------- + def initialize(db, filepath) + @filepath = filepath + @memo = db.engine.get_memo(@filepath) + end +end + +#--------------------------------------------------------------------------- +# KBBlob +#--------------------------------------------------------------------------- +class KBBlob + attr_reader :filepath, :blob + + #----------------------------------------------------------------------- + # initialize + #----------------------------------------------------------------------- + def initialize(db, filepath) + @filepath = filepath + @blob = db.engine.get_blob(@filepath) + end +end + + +#--------------------------------------------------------------------------- +# KBIndex +#--------------------------------------------------------------------------- +class KBIndex + include KBTypeConversionsMixin + + UNENCODE_RE = /&(?:amp|linefeed|carriage_return|substitute|pipe);/ + + #----------------------------------------------------------------------- + # initialize + #----------------------------------------------------------------------- + def initialize(table, index_fields) + @idx_arr = [] + @table = table + @index_fields = index_fields + @col_poss = index_fields.collect {|i| table.field_names.index(i) } + @col_names = index_fields + @col_types = index_fields.collect {|i| + table.field_types[table.field_names.index(i)]} + end + + #----------------------------------------------------------------------- + # get_idx + #----------------------------------------------------------------------- + def get_idx + return @idx_arr + end + + #----------------------------------------------------------------------- + # rebuild + #----------------------------------------------------------------------- + def rebuild(fptr) + @idx_arr.clear + + encrypted = @table.encrypted? + + # Skip header rec. + fptr.readline + + begin + # Loop through table. + while true + 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('|', @col_poss.max+2) + + # Create the index record by pulling out the record fields + # that make up this index and converting them to their + # native types. + idx_rec = [] + @col_poss.zip(@col_types).each do |col_pos, col_type| + idx_rec << convert_to(col_type, rec[col_pos]) + end + + # Were all the index fields for this record equal to NULL? + # Then don't add this index record to index array; skip to + # next record. + next if idx_rec.compact.empty? + + # Add recno to the end of this index record. + idx_rec << rec.first.to_i + + # Add index record to index array. + @idx_arr << idx_rec + end + # Here's how we break out of the loop... + rescue EOFError + end + end + + #----------------------------------------------------------------------- + # add_index_rec + #----------------------------------------------------------------------- + def add_index_rec(rec) + @idx_arr << @col_poss.zip(@col_types).collect do |col_pos, col_type| + convert_to(col_type, rec[col_pos]) + end + [rec.first.to_i] + end + + #----------------------------------------------------------------------- + # delete_index_rec + #----------------------------------------------------------------------- + def delete_index_rec(recno) + i = @idx_arr.rassoc(recno.to_i) + @idx_arr.delete_at(@idx_arr.index(i)) unless i.nil? + end + + #----------------------------------------------------------------------- + # update_index_rec + #----------------------------------------------------------------------- + def update_index_rec(rec) + delete_index_rec(rec.first.to_i) + add_index_rec(rec) + end +end + + +#--------------------------------------------------------------------------- +# KBRecnoIndex +#--------------------------------------------------------------------------- +class KBRecnoIndex + #----------------------------------------------------------------------- + # initialize + #----------------------------------------------------------------------- + def initialize(table) + @idx_hash = {} + @table = table + end + + #----------------------------------------------------------------------- + # get_idx + #----------------------------------------------------------------------- + def get_idx + return @idx_hash + end + + #----------------------------------------------------------------------- + # rebuild + #----------------------------------------------------------------------- + def rebuild(fptr) + @idx_hash.clear + + encrypted = @table.encrypted? + + 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('|', 2) + + @idx_hash[rec.first.to_i] = fpos + end + # Here's how we break out of the loop... + rescue EOFError + end + end + + #----------------------------------------------------------------------- + # add_index_rec + #----------------------------------------------------------------------- + def add_index_rec(recno, fpos) + raise 'Table already has index record for recno: %s' % recno if \ + @idx_hash.has_key?(recno.to_i) + @idx_hash[recno.to_i] = fpos + end + + #----------------------------------------------------------------------- + # update_index_rec + #----------------------------------------------------------------------- + def update_index_rec(recno, fpos) + raise 'Table has no index record for recno: %s' % recno unless \ + @idx_hash.has_key?(recno.to_i) + @idx_hash[recno.to_i] = fpos + end + + #----------------------------------------------------------------------- + # delete_index_rec + #----------------------------------------------------------------------- + def delete_index_rec(recno) + raise 'Table has no index record for recno: %s' % recno unless \ + @idx_hash.has_key?(recno.to_i) + @idx_hash.delete(recno.to_i) + end +end + + +#--------------------------------------------------------------------------- +# KBTableRec +#--------------------------------------------------------------------------- +class KBTableRec + include KBTypeConversionsMixin + + def initialize(tbl) + @tbl = tbl + end + + def populate(rec) + @tbl.field_names.zip(rec).each do |fn, val| + send("#{fn}=", val) + end + end + + def clear + @tbl.field_names.each do |fn| + send("#{fn}=", nil) + end + end +end + + +#--------------------------------------------------------- +# KBResultSet +#--------------------------------------------------------------------------- +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) + + @filter.each do |f| + get_meth_str = <<-END_OF_STRING + def #{f}() + if defined?(@#{f}) then + return @#{f} + else + @#{f} = self.collect { |x| x.#{f} } + return @#{f} + end + end + END_OF_STRING + self.class.class_eval(get_meth_str) + end + 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, + :Time => :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 + + +#--------------------------------------------------------------------------- +# Object +#--------------------------------------------------------------------------- +class Object + def full_const_get(name) + list = name.split("::") + obj = Object + list.each {|x| obj = obj.const_get(x) } + obj + end +end + + +#--------------------------------------------------------------------------- +# NilClass +#--------------------------------------------------------------------------- +class NilClass + #----------------------------------------------------------------------- + # method_missing + #----------------------------------------------------------------------- + # + # This code is necessary because if, inside a select condition code + # block, there is a case where you are trying to do an expression + # against a table field that is equal to nil, I don't want a method + # missing exception to occur. I just want the expression to be nil. I + # initially had this method returning false, but then I had an issue + # where I had a YAML field that was supposed to hold an Array. If the + # field was empty (i.e. nil) it was actually returning false when it + # should be returning nil. Since nil evaluates to false, it works if I + # return nil. + # Here's an example: + # #select { |r| r.speed > 300 } + # What happens if speed is nil (basically NULL in DBMS terms)? Without + # this code, an exception is going to be raised, which is not what we + # really want. We really want this expression to return nil. + def method_missing(method_id, *stuff) + return nil + end +end + + +#--------------------------------------------------------------------------- +# Symbol +#--------------------------------------------------------------------------- +class Symbol + #----------------------------------------------------------------------- + # -@ + #----------------------------------------------------------------------- + # + # This allows you to put a minus sign in front of a field name in order + # to specify descending sort order. + def -@ + ("-"+self.to_s).to_sym + end + + #----------------------------------------------------------------------- + # +@ + #----------------------------------------------------------------------- + # + # This allows you to put a plus sign in front of a field name in order + # to specify ascending sort order. + def +@ + ("+"+self.to_s).to_sym + end +end