module DBF
class Table
include Enumerable
attr_reader :column_count # The total number of columns (columns)
attr_reader :columns # An array of DBF::Column
attr_reader :version # Internal dBase version number
attr_reader :last_updated # Last updated datetime
attr_reader :memo_file_format # :fpt or :dpt
attr_reader :memo_block_size # The block size for memo records
attr_reader :options # The options hash that was used to initialize the table
attr_reader :data # DBF file handle
attr_reader :memo # Memo file handle
# Initializes a new DBF::Table
# Example:
# table = DBF::Table.new 'data.dbf'
def initialize(filename, options = {})
@data = File.open(filename, 'rb')
@memo = open_memo(filename)
@options = options
reload!
end
# Reloads the database and memo files
def reload!
@records = nil
get_header_info
get_memo_header_info if @memo
get_column_descriptors
build_db_index
end
# Returns true if there is a corresponding memo file
def has_memo_file?
@memo ? true : false
end
# The total number of active records.
def record_count
@db_index.size
end
# Returns an instance of DBF::Column for column_name. The column_name
# can be a specified as either a symbol or string.
def column(column_name)
@columns.detect {|f| f.name == column_name.to_s}
end
# An array of all the records contained in the database file. Each record is an instance
# of DBF::Record (or nil if the record is marked for deletion).
def records
self.to_a
end
alias_method :rows, :records
def each
0.upto(@record_count - 1) do |n|
seek_to_record(n)
unless deleted_record?
yield DBF::Record.new(self)
end
end
end
# Returns a DBF::Record (or nil if the record has been marked for deletion) for the record at index.
def record(index)
records[index]
end
# Find records using a simple ActiveRecord-like syntax.
#
# Examples:
# table = DBF::Table.new 'mydata.dbf'
#
# # Find record number 5
# table.find(5)
#
# # Find all records for Keith Morrison
# table.find :all, :first_name => "Keith", :last_name => "Morrison"
#
# # Find first record
# table.find :first, :first_name => "Keith"
#
# The command can be an id, :all, or :first.
# options is optional and, if specified, should be a hash where the keys correspond
# to column names in the database. The values will be matched exactly with the value
# in the database. If you specify more than one key, all values must match in order
# for the record to be returned. The equivalent SQL would be "WHERE key1 = 'value1'
# AND key2 = 'value2'".
def find(command, options = {})
results = options.empty? ? records : records.select {|record| all_values_match?(record, options)}
case command
when Fixnum
record(command)
when :all
results
when :first
results.first
end
end
alias_method :row, :record
# Returns a description of the current database file.
def version_description
VERSION_DESCRIPTIONS[version]
end
# Returns a database schema in the portable ActiveRecord::Schema format.
#
# xBase data types are converted to generic types as follows:
# - Number columns are converted to :integer if there are no decimals, otherwise
# they are converted to :float
# - Date columns are converted to :datetime
# - Logical columns are converted to :boolean
# - Memo columns are converted to :text
# - Character columns are converted to :string and the :limit option is set
# to the length of the character column
#
# Example:
# create_table "mydata" do |t|
# t.column :name, :string, :limit => 30
# t.column :last_update, :datetime
# t.column :is_active, :boolean
# t.column :age, :integer
# t.column :notes, :text
# end
def schema(path = nil)
s = "ActiveRecord::Schema.define do\n"
s << " create_table \"#{File.basename(@data.path, ".*")}\" do |t|\n"
columns.each do |column|
s << " t.column #{column.schema_definition}"
end
s << " end\nend"
if path
File.open(path, 'w') {|f| f.puts(s)}
else
s
end
end
# Returns the record at index by seeking to the record in the
# physical database file. See the documentation for the records method for
# information on how these two methods differ.
def get_record_from_file(index)
seek_to_record(@db_index[index])
Record.new(self)
end
# Dumps all records into a CSV file
def to_csv(filename = nil)
filename = File.basename(@data.path, '.dbf') + '.csv' if filename.nil?
FCSV.open(filename, 'w', :force_quotes => true) do |csv|
records.each do |record|
csv << record.to_a
end
end
end
private
def open_memo(file)
%w(fpt FPT dbt DBT).each do |extname|
filename = replace_extname(file, extname)
if File.exists?(filename)
@memo_file_format = extname.downcase.to_sym
return File.open(filename, 'rb')
end
end
nil
end
def replace_extname(filename, extension)
filename.sub(/#{File.extname(filename)[1..-1]}$/, extension)
end
def deleted_record?
if @data.read(1).unpack('a') == ['*']
@data.rewind
true
else
false
end
end
def get_header_info
@data.rewind
@version, @record_count, @header_length, @record_length = @data.read(DBF_HEADER_SIZE).unpack('H2 x3 V v2')
@column_count = (@header_length - DBF_HEADER_SIZE + 1) / DBF_HEADER_SIZE
end
def get_column_descriptors
@columns = []
@column_count.times do
name, type, length, decimal = @data.read(32).unpack('a10 x a x4 C2')
if length > 0
@columns << Column.new(name.strip, type, length, decimal)
end
end
# Reset the column count in case any were skipped
@column_count = @columns.size
@columns
end
def get_memo_header_info
@memo.rewind
if @memo_file_format == :fpt
@memo_next_available_block, @memo_block_size = @memo.read(FPT_HEADER_SIZE).unpack('N x2 n')
@memo_block_size = 0 if @memo_block_size.nil?
else
@memo_block_size = 512
@memo_next_available_block = File.size(@memo.path) / @memo_block_size
end
end
def seek(offset)
@data.seek(@header_length + offset)
end
def seek_to_record(index)
seek(index * @record_length)
end
def build_db_index
@db_index = []
@deleted_records = []
0.upto(@record_count - 1) do |n|
seek_to_record(n)
if deleted_record?
@deleted_records << n
else
@db_index << n
end
end
end
def all_values_match?(record, options)
options.map {|key, value| record.attributes[key.to_s.underscore] == value}.all?
end
end
end