├── Gemfile ├── lib ├── dbf │ ├── version.rb │ ├── column │ │ ├── dbase.rb │ │ ├── foxpro.rb │ │ └── base.rb │ ├── attributes.rb │ ├── util.rb │ ├── memo │ │ ├── dbase4.rb │ │ ├── dbase3.rb │ │ ├── foxpro.rb │ │ └── base.rb │ ├── record.rb │ ├── encodings.yml │ └── table.rb └── dbf.rb ├── .gitignore ├── spec ├── fixtures │ ├── cp1251.dbf │ ├── dbase_30.dbf │ ├── dbase_30.fpt │ ├── dbase_31.dbf │ ├── dbase_83.dbf │ ├── dbase_83.dbt │ ├── dbase_8b.dbf │ ├── dbase_8b.dbt │ ├── dbase_f5.dbf │ ├── dbase_f5.fpt │ ├── dbase_83_schema.txt │ └── dbase_03.dbf ├── spec_helper.rb ├── rvm_ruby_runner.rb └── dbf │ ├── record_spec.rb │ ├── file_formats_spec.rb │ ├── table_spec.rb │ └── column_spec.rb ├── .travis.yml ├── MIT-LICENSE ├── bin └── dbf ├── dbf.gemspec ├── Rakefile ├── docs └── supported_types.markdown ├── README.md └── CHANGELOG.md /Gemfile: -------------------------------------------------------------------------------- 1 | gemspec 2 | source :rubygems 3 | -------------------------------------------------------------------------------- /lib/dbf/version.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | VERSION = '1.7.5' 3 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .bundle 3 | .rvmrc 4 | Gemfile.lock 5 | -------------------------------------------------------------------------------- /spec/fixtures/cp1251.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/cp1251.dbf -------------------------------------------------------------------------------- /spec/fixtures/dbase_30.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_30.dbf -------------------------------------------------------------------------------- /spec/fixtures/dbase_30.fpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_30.fpt -------------------------------------------------------------------------------- /spec/fixtures/dbase_31.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_31.dbf -------------------------------------------------------------------------------- /spec/fixtures/dbase_83.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_83.dbf -------------------------------------------------------------------------------- /spec/fixtures/dbase_83.dbt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_83.dbt -------------------------------------------------------------------------------- /spec/fixtures/dbase_8b.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_8b.dbf -------------------------------------------------------------------------------- /spec/fixtures/dbase_8b.dbt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_8b.dbt -------------------------------------------------------------------------------- /spec/fixtures/dbase_f5.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_f5.dbf -------------------------------------------------------------------------------- /spec/fixtures/dbase_f5.fpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ujifgc/dbf/master/spec/fixtures/dbase_f5.fpt -------------------------------------------------------------------------------- /lib/dbf/column/dbase.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Column 3 | class Dbase < Base 4 | 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.9.3 3 | - 1.9.2 4 | - 1.8.7 5 | - ree 6 | - jruby 7 | branches: 8 | only: 9 | - master 10 | -------------------------------------------------------------------------------- /lib/dbf/attributes.rb: -------------------------------------------------------------------------------- 1 | class Attributes < Hash 2 | def []=(key, value) 3 | merge!(key => value) 4 | merge!(Util.underscore(key) => value) 5 | end 6 | end -------------------------------------------------------------------------------- /lib/dbf/column/foxpro.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Column 3 | class Foxpro < Base 4 | def unpack_binary(value) #nodoc 5 | value.unpack('d')[0] 6 | end 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /lib/dbf/util.rb: -------------------------------------------------------------------------------- 1 | class Util 2 | def self.underscore(string) #nodoc 3 | string.gsub(/::/, '/'). 4 | gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). 5 | gsub(/([a-z\d])([A-Z])/,'\1_\2'). 6 | tr('-', '_'). 7 | downcase 8 | end 9 | end -------------------------------------------------------------------------------- /lib/dbf/memo/dbase4.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Memo 3 | class Dbase4 < Base 4 | def build_memo(start_block) #nodoc 5 | @data.seek offset(start_block) 6 | @data.read(@data.read(BLOCK_HEADER_SIZE).unpack("x4L").first) 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.dirname(__FILE__) + '/../lib/') 2 | require 'dbf' 3 | require 'rspec' 4 | 5 | DB_PATH = File.dirname(__FILE__) + '/fixtures' unless defined?(DB_PATH) 6 | 7 | if RUBY_VERSION == "1.8.6" 8 | # warn 'ruby-1.8.6: defining Array#reduce as alias of Array#inject' 9 | class Array 10 | alias_method :reduce, :inject 11 | end 12 | end 13 | 14 | RSpec.configure do |config| 15 | 16 | end 17 | -------------------------------------------------------------------------------- /lib/dbf/memo/dbase3.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Memo 3 | class Dbase3 < Base 4 | def build_memo(start_block) #nodoc 5 | @data.seek offset(start_block) 6 | memo_string = "" 7 | begin 8 | block = @data.read(block_size).gsub(/(\000|\032)/, '') 9 | memo_string << block 10 | end until block.size < block_size 11 | memo_string 12 | end 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /lib/dbf.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | require 'yaml' 4 | require 'csv' 5 | if CSV.const_defined? :Reader 6 | require 'fastercsv' 7 | end 8 | 9 | require 'dbf/util' 10 | require 'dbf/attributes' 11 | require 'dbf/record' 12 | require 'dbf/column/base' 13 | require 'dbf/column/dbase' 14 | require 'dbf/column/foxpro' 15 | require 'dbf/table' 16 | require 'dbf/memo/base' 17 | require 'dbf/memo/dbase3' 18 | require 'dbf/memo/dbase4' 19 | require 'dbf/memo/foxpro' -------------------------------------------------------------------------------- /spec/rvm_ruby_runner.rb: -------------------------------------------------------------------------------- 1 | class RvmRubyRunner 2 | def self.run(ruby_string) 3 | output = `rvm use #{ruby_string}@dbf --create; bundle install; rspec` 4 | puts output if ENV['DEBUG=1'] 5 | if output =~ /To install do/ 6 | "#{ruby_string.rjust 12}: not installed" 7 | elsif output =~ /Finished/m 8 | results = output.lines.to_a[-1].strip 9 | time = output.lines.to_a[-2].strip 10 | "#{ruby_string.rjust 12}: #{results}, #{time}" 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /spec/fixtures/dbase_83_schema.txt: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table "dbase_83" do |t| 3 | t.column "id", :integer 4 | t.column "catcount", :integer 5 | t.column "agrpcount", :integer 6 | t.column "pgrpcount", :integer 7 | t.column "order", :integer 8 | t.column "code", :string, :limit => 50 9 | t.column "name", :string, :limit => 100 10 | t.column "thumbnail", :string, :limit => 254 11 | t.column "image", :string, :limit => 254 12 | t.column "price", :float 13 | t.column "cost", :float 14 | t.column "desc", :text 15 | t.column "weight", :float 16 | t.column "taxable", :boolean 17 | t.column "active", :boolean 18 | end 19 | end -------------------------------------------------------------------------------- /lib/dbf/memo/foxpro.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Memo 3 | class Foxpro < Base 4 | FPT_HEADER_SIZE = 512 5 | 6 | def build_memo(start_block) #nodoc 7 | @data.seek offset(start_block) 8 | 9 | memo_type, memo_size, memo_string = @data.read(block_size).unpack("NNa*") 10 | return nil unless memo_type == 1 && memo_size > 0 11 | 12 | if memo_size > block_content_size 13 | memo_string << @data.read(content_size(memo_size)) 14 | else 15 | memo_string = memo_string[0, memo_size] 16 | end 17 | memo_string 18 | end 19 | 20 | private 21 | 22 | def block_size #nodoc 23 | @block_size ||= begin 24 | @data.rewind 25 | @data.read(FPT_HEADER_SIZE).unpack('x6n').first || 0 26 | end 27 | end 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /lib/dbf/memo/base.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Memo 3 | class Base 4 | BLOCK_HEADER_SIZE = 8 5 | 6 | def self.open(filename, version) 7 | self.new File.open(filename, 'rb'), version 8 | end 9 | 10 | def initialize(data, version) 11 | @data, @version = data, version 12 | end 13 | 14 | def get(start_block) 15 | if start_block > 0 16 | build_memo start_block 17 | end 18 | end 19 | 20 | def close 21 | @data.close 22 | end 23 | 24 | private 25 | 26 | def offset(start_block) #nodoc 27 | start_block * block_size 28 | end 29 | 30 | def content_size(memo_size) #nodoc 31 | (memo_size - block_size) + BLOCK_HEADER_SIZE 32 | end 33 | 34 | def block_content_size #nodoc 35 | @block_content_size ||= block_size - BLOCK_HEADER_SIZE 36 | end 37 | 38 | def block_size #nodoc 39 | 512 40 | end 41 | end 42 | end 43 | end -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2011 Keith Morrison 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /bin/dbf: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -s 2 | 3 | require 'rubygems' 4 | require 'dbf' 5 | 6 | $a ||= false 7 | $s ||= false 8 | $c ||= false 9 | 10 | if defined? $h then 11 | puts "usage: #{File.basename(__FILE__)} [-h|-s|-a|-c] filename" 12 | puts " -h = print this message" 13 | puts " -s = print summary information" 14 | puts " -a = create an ActiveRecord::Schema" 15 | puts " -c = create a csv file" 16 | else 17 | 18 | filename = ARGV.shift 19 | abort "You must supply a filename on the command line" unless filename 20 | 21 | # create an ActiveRecord::Schema 22 | if $a 23 | table = DBF::Table.new filename 24 | puts table.schema 25 | end 26 | 27 | if $s 28 | table = DBF::Table.new filename 29 | puts 30 | puts "Database: #{filename}" 31 | puts "Type: (#{table.version}) #{table.version_description}" 32 | puts "Memo File: #{table.has_memo_file? ? 'true' : false}" 33 | puts "Records: #{table.record_count}" 34 | 35 | puts "\nFields:" 36 | puts "Name Type Length Decimal" 37 | puts "-" * 78 38 | table.columns.each do |f| 39 | puts "%-16s %-10s %-10s %-10s" % [f.name, f.type, f.length, f.decimal] 40 | end 41 | end 42 | 43 | if $c 44 | table = DBF::Table.new filename 45 | table.to_csv 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /dbf.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib/', __FILE__) 2 | $:.unshift lib unless $:.include?(lib) 3 | require 'dbf/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'dbf' 7 | s.version = DBF::VERSION 8 | s.authors = ['Keith Morrison'] 9 | s.email = 'keithm@infused.org' 10 | s.homepage = 'http://github.com/infused/dbf' 11 | s.summary = 'Read xBase files' 12 | s.description = 'A small fast library for reading dBase, xBase, Clipper and FoxPro database files.' 13 | 14 | s.executables = ['dbf'] 15 | s.rdoc_options = ['--charset=UTF-8'] 16 | s.extra_rdoc_files = ['README.md', 'CHANGELOG.md', 'MIT-LICENSE'] 17 | s.files = Dir['[A-Z]*', '{bin,docs,lib,spec}/**/*', 'dbf.gemspec'] 18 | s.test_files = Dir.glob('spec/**/*_spec.rb') 19 | s.require_paths = ['lib'] 20 | 21 | s.required_rubygems_version = '>= 1.3.0' 22 | if RUBY_VERSION.to_f < 1.9 23 | s.add_dependency 'fastercsv', '~> 1.5.4' 24 | end 25 | s.add_development_dependency 'rspec', '~> 2.9.0' 26 | s.add_development_dependency 'rake', '~> 0.9.2' 27 | 28 | if RUBY_VERSION == RUBY_VERSION.to_f >= 1.9 29 | s.add_development_dependency 'rdoc', '~> 2.5.0' 30 | else 31 | s.add_development_dependency 'rdoc', '~> 3.11' 32 | end 33 | 34 | # if RUBY_VERSION.to_f >= 1.9 35 | # s.add_development_dependency 'ruby-debug19' 36 | # elsif RUBY_VERSION != '1.8.6' 37 | # s.add_development_dependency 'ruby-debug' 38 | # end 39 | # s.add_development_dependency 'metric_fu' 40 | end 41 | 42 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler/setup'; 5 | Bundler.setup(:default, :development) 6 | 7 | require 'rspec/core/rake_task' 8 | RSpec::Core::RakeTask.new :spec do |t| 9 | t.rspec_opts = %w(--color) 10 | end 11 | 12 | RSpec::Core::RakeTask.new :specdoc do |t| 13 | t.rspec_opts = %w(-fl) 14 | end 15 | 16 | require 'rake' 17 | require 'rdoc/task' 18 | Rake::RDocTask.new { |rdoc| 19 | rdoc.rdoc_dir = 'doc' 20 | rdoc.title = "DBF - A small fast library for reading dBase, xBase, Clipper and FoxPro database files." 21 | rdoc.options << '--line-numbers' 22 | rdoc.template = "#{ENV['template']}.rb" if ENV['template'] 23 | rdoc.rdoc_files.include('README.md', 'docs/supported_types.markdown', 'lib/**/*.rb') 24 | } 25 | 26 | task :default => :spec 27 | 28 | desc "Open an irb session preloaded with this library" 29 | task :console do 30 | sh "irb -rubygems -I lib -r dbf.rb" 31 | end 32 | 33 | # require 'metric_fu' 34 | # MetricFu::Configuration.run do |config| 35 | # config.rcov[:test_files] = ['spec/**/*_spec.rb'] 36 | # config.rcov[:rcov_opts] << "-Ispec" 37 | # end 38 | 39 | namespace :test do 40 | task :rubies do 41 | require File.expand_path('spec/rvm_ruby_runner', File.dirname(__FILE__)) 42 | 43 | current_rvm = `rvm info`.lines.to_a[1] 44 | 45 | rubies = %w( 46 | ree-1.8.6 47 | ree-1.8.7 48 | jruby-1.6.2 49 | jruby-1.6.3 50 | jruby-1.6.4 51 | jruby-1.6.5 52 | ruby-1.8.6 53 | ruby-1.8.7 54 | ruby-1.9.1 55 | ruby-1.9.2 56 | ruby-1.9.3 57 | ) 58 | rubies.each do |version| 59 | puts RvmRubyRunner.run(version) 60 | end 61 | 62 | `rvm use #{current_rvm}` 63 | end 64 | end -------------------------------------------------------------------------------- /spec/dbf/record_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe DBF::Record do 4 | 5 | describe '#to_a' do 6 | it 'should return an ordered array of attribute values' do 7 | table = DBF::Table.new "#{DB_PATH}/dbase_8b.dbf" 8 | 9 | record = table.record(0) 10 | record.to_a.should == ["One", 1.0, Date.new(1970, 1, 1), true, 1.23456789012346, "First memo\r\n\037 \037 \037 \037 "] 11 | 12 | record = table.record(9) 13 | record.to_a.should == ["Ten records stored in this database", 10.0, nil, false, 0.1, nil] 14 | end 15 | end 16 | 17 | describe '#==' do 18 | before do 19 | table = DBF::Table.new "#{DB_PATH}/dbase_8b.dbf" 20 | @record = table.record(9) 21 | end 22 | 23 | it 'should be false if other does not have attributes' do 24 | (@record == mock('other')).should be_false 25 | end 26 | 27 | it 'should be true if other attributes match' do 28 | attributes = {:x => 1, :y => 2} 29 | @record.stub!(:attributes).and_return(attributes) 30 | other = mock('object', :attributes => attributes) 31 | (@record == other).should be_true 32 | end 33 | end 34 | 35 | describe 'column accessors' do 36 | let(:table) { DBF::Table.new "#{DB_PATH}/dbase_8b.dbf"} 37 | 38 | it 'should define accessor methods for each column' do 39 | record = table.find(0) 40 | record.should respond_to(:character) 41 | record.character.should == 'One' 42 | end 43 | end 44 | 45 | describe 'column data for table' do 46 | let(:table) { DBF::Table.new "#{DB_PATH}/cp1251.dbf"} 47 | 48 | let(:record) { table.find(0) } 49 | it 'should automatically encodes to default system encoding' do 50 | if table.supports_encoding? 51 | record.name.encoding.should == Encoding.default_external 52 | record.name.encode("UTF-8").unpack("H4").should == ["d0b0"] # russian a 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/dbf/record.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | # An instance of DBF::Record represents a row in the DBF file 3 | class Record 4 | # Initialize a new DBF::Record 5 | # 6 | # @data [String, StringIO] data 7 | # @columns [Column] 8 | # @version [String] 9 | # @memo [DBF::Memo] 10 | def initialize(data, columns, version, memo) 11 | @data = StringIO.new(data) 12 | @columns, @version, @memo = columns, version, memo 13 | @column_names = @columns.map {|column| column.underscored_name} 14 | define_accessors 15 | end 16 | 17 | # Equality 18 | # 19 | # @param [DBF::Record] other 20 | # @return [Boolean] 21 | def ==(other) 22 | other.respond_to?(:attributes) && other.attributes == attributes 23 | end 24 | 25 | # Maps a row to an array of values 26 | # 27 | # @return [Array] 28 | def to_a 29 | @column_names.map {|name| attributes[name]} 30 | end 31 | 32 | # Do all search parameters match? 33 | # 34 | # @param [Hash] options 35 | # @return [Boolean] 36 | def match?(options) 37 | options.all? {|key, value| attributes[Util.underscore(key.to_s)] == value} 38 | end 39 | 40 | # @return [Hash] 41 | def attributes 42 | @attributes ||= begin 43 | attributes = Attributes.new 44 | @columns.each {|column| attributes[column.name] = init_attribute(column)} 45 | attributes 46 | end 47 | end 48 | 49 | private 50 | 51 | def define_accessors #nodoc 52 | @column_names.each do |name| 53 | next if respond_to? name 54 | self.class.send(:define_method, name) do 55 | attributes[name] 56 | end 57 | end 58 | end 59 | 60 | def init_attribute(column) #nodoc 61 | value = if column.memo? 62 | @memo.get get_memo_start_block(column) 63 | else 64 | unpack_data(column) 65 | end 66 | column.type_cast value 67 | end 68 | 69 | def get_memo_start_block(column) #nodoc 70 | if %w(30 31).include?(@version) 71 | @data.read(column.length).unpack('V').first 72 | else 73 | unpack_data(column).to_i 74 | end 75 | end 76 | 77 | def unpack_data(column) #nodoc 78 | @data.read(column.length).unpack("a#{column.length}").first 79 | end 80 | 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/dbf/encodings.yml: -------------------------------------------------------------------------------- 1 | # inspired by http://trac.osgeo.org/gdal/ticket/2864 2 | 3 | "01": "cp437" # U.S. MS–DOS 4 | "02": "cp850" # International MS–DOS 5 | "03": "cp1252" # Windows ANSI 6 | "08": "cp865" # Danish OEM 7 | "09": "cp437" # Dutch OEM 8 | "0a": "cp850" # Dutch OEM* 9 | "0b": "cp437" # Finnish OEM 10 | "0d": "cp437" # French OEM 11 | "0e": "cp850" # French OEM* 12 | "0f": "cp437" # German OEM 13 | "10": "cp850" # German OEM* 14 | "11": "cp437" # Italian OEM 15 | "12": "cp850" # Italian OEM* 16 | "13": "cp932" # Japanese Shift-JIS 17 | "14": "cp850" # Spanish OEM* 18 | "15": "cp437" # Swedish OEM 19 | "16": "cp850" # Swedish OEM* 20 | "17": "cp865" # Norwegian OEM 21 | "18": "cp437" # Spanish OEM 22 | "19": "cp437" # English OEM (Britain) 23 | "1a": "cp850" # English OEM (Britain)* 24 | "1b": "cp437" # English OEM (U.S.) 25 | "1c": "cp863" # French OEM (Canada) 26 | "1d": "cp850" # French OEM* 27 | "1f": "cp852" # Czech OEM 28 | "22": "cp852" # Hungarian OEM 29 | "23": "cp852" # Polish OEM 30 | "24": "cp860" # Portuguese OEM 31 | "25": "cp850" # Portuguese OEM* 32 | "26": "cp866" # Russian OEM 33 | "37": "cp850" # English OEM (U.S.)* 34 | "40": "cp852" # Romanian OEM 35 | "4d": "cp936" # Chinese GBK (PRC) 36 | "4e": "cp949" # Korean (ANSI/OEM) 37 | "4f": "cp950" # Chinese Big5 (Taiwan) 38 | "50": "cp874" # Thai (ANSI/OEM) 39 | "57": "cp1252" # ANSI 40 | "58": "cp1252" # Western European ANSI 41 | "59": "cp1252" # Spanish ANSI 42 | "64": "cp852" # Eastern European MS–DOS 43 | "65": "cp866" # Russian MS–DOS 44 | "66": "cp865" # Nordic MS–DOS 45 | "67": "cp861" # Icelandic MS–DOS 46 | "6a": "cp737" # Greek MS–DOS (437G) 47 | "6b": "cp857" # Turkish MS–DOS 48 | "6c": "cp863" # French–Canadian MS–DOS 49 | "78": "cp950" # Taiwan Big 5 50 | "79": "cp949" # Hangul (Wansung) 51 | "7a": "cp936" # PRC GBK 52 | "7b": "cp932" # Japanese Shift-JIS 53 | "7c": "cp874" # Thai Windows/MS–DOS 54 | "86": "cp737" # Greek OEM 55 | "87": "cp852" # Slovenian OEM 56 | "88": "cp857" # Turkish OEM 57 | "c8": "cp1250" # Eastern European Windows 58 | "c9": "cp1251" # Russian Windows 59 | "ca": "cp1254" # Turkish Windows 60 | "cb": "cp1253" # Greek Windows 61 | "cc": "cp1257" # Baltic Windows 62 | -------------------------------------------------------------------------------- /lib/dbf/column/base.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | module Column 3 | class LengthError < StandardError; end 4 | class NameError < StandardError; end 5 | 6 | class Base 7 | attr_reader :name, :type, :length, :decimal 8 | 9 | # Initialize a new DBF::Column 10 | # 11 | # @param [String] name 12 | # @param [String] type 13 | # @param [Fixnum] length 14 | # @param [Fixnum] decimal 15 | def initialize(name, type, length, decimal, version, encoding=nil) 16 | @name, @type, @length, @decimal, @version, @encoding = clean(name), type, length, decimal, version, encoding 17 | 18 | raise LengthError, "field length must be greater than 0" unless length > 0 19 | raise NameError, "column name cannot be empty" if @name.length == 0 20 | end 21 | 22 | # Cast value to native type 23 | # 24 | # @param [String] value 25 | # @return [Fixnum, Float, Date, DateTime, Boolean, String] 26 | def type_cast(value) 27 | case type 28 | when 'N' then unpack_number(value) 29 | when 'I' then unpack_unsigned_long(value) 30 | when 'F' then value.to_f 31 | when 'Y' then unpack_unsigned_long(value) / 10000.0 32 | when 'D' then decode_date(value) 33 | when 'T' then decode_datetime(value) 34 | when 'L' then boolean(value) 35 | when 'B' then unpack_binary(value) 36 | else encode_string(value.to_s).strip 37 | end 38 | end 39 | 40 | def memo? 41 | @memo ||= type == 'M' 42 | end 43 | 44 | # Schema definition 45 | # 46 | # @return [String] 47 | def schema_definition 48 | "\"#{underscored_name}\", #{schema_data_type}\n" 49 | end 50 | 51 | def underscored_name 52 | @underscored_name ||= Util.underscore(name) 53 | end 54 | 55 | private 56 | 57 | def decode_date(value) #nodoc 58 | value.gsub!(' ', '0') 59 | value !~ /\S/ ? nil : Date.parse(value) 60 | rescue 61 | nil 62 | end 63 | 64 | def decode_datetime(value) #nodoc 65 | days, milliseconds = value.unpack('l2') 66 | seconds = (milliseconds / 1000).to_i 67 | DateTime.jd(days, (seconds/3600).to_i, (seconds/60).to_i % 60, seconds % 60) rescue nil 68 | end 69 | 70 | def unpack_number(value) #nodoc 71 | decimal.zero? ? value.to_i : value.to_f 72 | end 73 | 74 | def unpack_unsigned_long(value) #nodoc 75 | value.unpack('V')[0] 76 | end 77 | 78 | def unpack_binary(value) #nodoc 79 | end 80 | 81 | def boolean(value) #nodoc 82 | value.strip =~ /^(y|t)$/i ? true : false 83 | end 84 | 85 | def encode_string(value) #nodoc 86 | @encoding ? value.force_encoding(@encoding).encode(Encoding.default_external, :undef => :replace, :invalid => :replace) : value 87 | end 88 | 89 | def schema_data_type #nodoc 90 | case type 91 | when "N", "F" 92 | decimal > 0 ? ":float" : ":integer" 93 | when "I" 94 | ":integer" 95 | when "Y" 96 | ":decimal, :precision => 15, :scale => 4" 97 | when "D" 98 | ":date" 99 | when "T" 100 | ":datetime" 101 | when "L" 102 | ":boolean" 103 | when "M" 104 | ":text" 105 | when "B" 106 | if DBF::Table::FOXPRO_VERSIONS.keys.include?(@version) 107 | decimal > 0 ? ":float" : ":integer" 108 | else 109 | ":text" 110 | end 111 | else 112 | ":string, :limit => #{length}" 113 | end 114 | end 115 | 116 | def clean(value) #nodoc 117 | first_null = value.index("\x00") 118 | value = value[0, first_null] if first_null 119 | value.gsub(/[^\x20-\x7E]/, "") 120 | end 121 | 122 | end 123 | end 124 | end -------------------------------------------------------------------------------- /docs/supported_types.markdown: -------------------------------------------------------------------------------- 1 | # DBF supported data types 2 | 3 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 4 | | Version | Description | C | N | L | D | M | F | B | G | P | Y | T | I | V | X | @ | O | + | 5 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 6 | | 02 | FoxBase | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | 7 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 8 | | 03 | dBase III without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | 9 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 10 | | 04 | dBase IV without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | 11 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 12 | | 05 | dBase V without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | 13 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 14 | | 07 | Visual Objects 1.x | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | 15 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 16 | | 30 | Visual FoxPro | Y | Y | Y | Y | - | Y | Y | N | N | N | N | Y | N | N | N | N | - | 17 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 18 | | 31 | Visual FoxPro with AutoIncrement | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | Y | N | N | N | N | N | 19 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 20 | | 7b | dBase IV with memo file | Y | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | 21 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 22 | | 83 | dBase III with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | 23 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 24 | | 87 | Visual Objects 1.x with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | 25 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 26 | | 8b | dBase IV with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | N | - | - | - | 27 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 28 | | 8e | dBase IV with SQL table | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | N | - | - | - | 29 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 30 | | f5 | FoxPro with memo file | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | Y | N | N | N | N | N | 31 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 32 | | fb | FoxPro without memo file | Y | Y | Y | Y | - | Y | Y | N | N | N | N | Y | N | N | N | N | N | 33 | +---------+-----------------------------------+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 34 | 35 | Data type descriptions 36 | 37 | * C = Character 38 | * N = Number 39 | * L = Logical 40 | * D = Date 41 | * M = Memo 42 | * F = Float 43 | * B = Binary 44 | * G = General 45 | * P = Picture 46 | * Y = Currency 47 | * T = DateTime 48 | * I = Integer 49 | * V = VariField 50 | * X = SQL compat 51 | * @ = Timestamp 52 | * O = Double 53 | * + = Autoincrement -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DBF [![Build Status](https://secure.travis-ci.org/infused/dbf.png)](http://travis-ci.org/infused/dbf) 2 | 3 | DBF is a small fast library for reading dBase, xBase, Clipper and FoxPro 4 | database files 5 | 6 | * Project page: 7 | * API Documentation: 8 | * Report bugs: 9 | * Questions: Email and put DBF somewhere in the 10 | subject line 11 | 12 | ## Compatibility 13 | 14 | DBF is tested to work with the following versions of ruby: 15 | 16 | * MRI Ruby 1.8.6, 1.8.7, 1.9.1, 1.9.2 and 1.9.3 17 | * JRuby 1.6.2, 1.6.3, 1.6.4, and 1.6.5 18 | * REE 1.8.6, 1.8.7 19 | 20 | ## Installation 21 | 22 | gem install dbf 23 | 24 | ## Basic Usage 25 | 26 | Open a DBF file: 27 | 28 | require 'dbf' 29 | widgets = DBF::Table.new("widgets.dbf") 30 | 31 | Enumerate all records 32 | 33 | widgets.each do |record| 34 | puts record.name 35 | puts record.email 36 | end 37 | 38 | Find a single record 39 | 40 | widget.find(6) 41 | 42 | Attributes can also be accessed through the attributes hash in original or 43 | underscored form or as an accessor method using the underscored name. (Note 44 | that find() will return nil if the requested record has been deleted and not 45 | yet pruned from the database) 46 | 47 | widget.find(4).attributes["SlotNumber"] 48 | widget.find(4).attributes["slot_number"] 49 | widget.find(4).slot_number 50 | 51 | Search for records using a simple hash format. Multiple search criteria are 52 | ANDed. Use the block form if the resulting recordset could be large, otherwise 53 | all records will be loaded into memory. 54 | 55 | # find all records with slot_number equal to s42 56 | widgets.find(:all, :slot_number => 's42') do |widget| 57 | # the record will be nil if deleted, but not yet pruned from the database 58 | if widget 59 | puts widget.serial_number 60 | end 61 | end 62 | 63 | # find the first record with slot_number equal to s42 64 | widgets.find :first, :slot_number => 's42' 65 | 66 | # find record number 10 67 | widgets.find(10) 68 | 69 | ## Migrating to ActiveRecord 70 | 71 | An example of migrating a DBF book table to ActiveRecord using a migration: 72 | 73 | require 'dbf' 74 | 75 | class Book < ActiveRecord::Base; end 76 | 77 | class CreateBooks < ActiveRecord::Migration 78 | def self.up 79 | table = DBF::Table.new('db/dbf/books.dbf') 80 | eval(table.schema) 81 | 82 | Book.reset_column_information 83 | table.each do |record| 84 | Book.create(record.attributes) 85 | end 86 | end 87 | 88 | def self.down 89 | drop_table :books 90 | end 91 | end 92 | 93 | ## Command-line utility 94 | 95 | A small command-line utility called dbf is installed along with the gem. 96 | 97 | $ dbf -h 98 | usage: dbf [-h|-s|-a] filename 99 | -h = print this message 100 | -s = print summary information 101 | -a = create an ActiveRecord::Schema 102 | -c = create a csv file 103 | 104 | Create an executable ActiveRecord schema: 105 | 106 | dbf -a books.dbf > books_schema.rb 107 | 108 | Dump all records to a CSV file: 109 | 110 | dbf -c books.dbf > books.csv 111 | 112 | ## dBase version support 113 | 114 | The basic dBase data types are generally supported well. Support for the 115 | advanced data types in dbase V and FoxPro are still experimental or not 116 | supported. If you have insight into how any of the unsupported data types are 117 | implemented, please give me a shout. FoxBase/dBase II files are not supported 118 | at this time. 119 | 120 | See 121 | [doc/supported_types.markdown](docs/supported_types.markdown) 122 | for a full list of supported column types. 123 | 124 | ## Limitations 125 | 126 | * DBF is read-only 127 | * Index files are not utilized 128 | 129 | ## License 130 | 131 | Copyright (c) 2006-2012 Keith Morrison 132 | 133 | Permission is hereby granted, free of charge, to any person 134 | obtaining a copy of this software and associated documentation 135 | files (the "Software"), to deal in the Software without 136 | restriction, including without limitation the rights to use, 137 | copy, modify, merge, publish, distribute, sublicense, and/or sell 138 | copies of the Software, and to permit persons to whom the 139 | Software is furnished to do so, subject to the following 140 | conditions: 141 | 142 | The above copyright notice and this permission notice shall be 143 | included in all copies or substantial portions of the Software. 144 | 145 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 146 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 147 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 148 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 149 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 150 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 151 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 152 | OTHER DEALINGS IN THE SOFTWARE. 153 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.7.5 2 | - fixes FoxPro currency (Y) fields 3 | 4 | # 1.7.4 5 | - Replace Memo Type with Memo File boolean in command-line utility summary output 6 | 7 | # 1.7.3 8 | - find_all/find_first should ignore deleted records 9 | 10 | # 1.7.2 11 | - Fix integer division under Ruby 1.8 when requiring mathn 12 | standard library (see http://bugs.ruby-lang.org/issues/2121) 13 | 14 | # 1.7.1 15 | - Fix Table.FOXPRO_VERSIONS breakage on Ruby 1.8 16 | 17 | # 1.7.0 18 | - allow DBF::Table to work with dbf data in memory 19 | - allow DBF::Table#to_csv to write to STDOUT 20 | 21 | # 1.6.7 22 | - memo columns return nil when no memo file found 23 | 24 | # 1.6.6 25 | - add binary data type support to ActiveRecord schema output 26 | 27 | # 1.6.5 28 | - support for visual foxpro double (b) data type 29 | 30 | # 1.6.3 31 | - Replace invalid chars with 'unicode replacement character' (U+FFFD) 32 | 33 | ## 1.6.2 34 | - add Table#filename method 35 | - Rakefile now loads gems with bundler 36 | - add Table#supports_encoding? 37 | - simplify encodings.yml loader 38 | - add rake and rdoc as development dependencies 39 | - simplify open_memo file search logic 40 | - remove unnecessary requires in spec helper 41 | - fix cli summary 42 | 43 | ## 1.6.1 44 | - fix YAML issue when using MRI version > 1.9.1 45 | - remove Table#seek_to_index and Table#current_record private methods 46 | 47 | ## 1.6.0 48 | - remove activesupport gem dependency 49 | 50 | ## 1.5.0 51 | 52 | - Significant internal restructuring and performance improvements. Initial 53 | testing shows 4x faster performance. 54 | 55 | ## 1.3.0 56 | 57 | - Only load what's needed from activesupport 3.0 58 | - Updatate fastercsv dependency to 1.5.3 59 | - Remove use of 'returning' method 60 | - Remove jeweler in favor of manual gemspec creation 61 | - Move Table#all_values_match? to Record#match? 62 | - Add attr_reader for Record#table 63 | - Use method_defined? instead of respond_to? when defining attribute accessors 64 | - Move memo file check into get_memo_header_info 65 | - Remove unnecessary seek_to_record in Table#each 66 | - Add rake console task 67 | - New Attribute class 68 | - Add a helper method for memo column type 69 | - Move constants into the classes where they are used 70 | - Use bundler 71 | 72 | ## 1.2.9 73 | 74 | - Retain trailing whitespace in memos 75 | 76 | ## 1.2.8 77 | 78 | - Handle missing zeros in date values [#11] 79 | 80 | ## 1.2.7 81 | 82 | - MIT License 83 | 84 | ## 1.2.6 85 | 86 | - Support for Ruby 1.9.2 87 | 88 | ## 1.2.5 89 | 90 | - Remove ruby warning switch 91 | - Requires activesupport version 2.3.5 92 | 93 | ## 1.2.4 94 | 95 | - Add csv output option to dbf command-line utility 96 | - Read Visual FoxPro memos 97 | 98 | ## 1.2.3 99 | 100 | - Small performance gain when unpacking values from the dbf file 101 | - Correctly handle FoxPro's integer data type 102 | 103 | ## 1.2.2 104 | 105 | - Handle invalid date fields 106 | 107 | ## 1.2.1 108 | 109 | - Add support for F field type (Float) 110 | 111 | ## 1.2.0 112 | 113 | - Add Table#to_a 114 | 115 | ## 1.1.1 116 | 117 | - Return invalid DateTime columns as nil 118 | 119 | ## 1.1.0 120 | 121 | - Add support for large table that will not fit into memory 122 | 123 | ## 1.0.13 124 | 125 | - Allow passing an array of ids to find 126 | 127 | ## 1.0.11 128 | 129 | - Attributes are now accessible by original or underscored name 130 | 131 | ## 1.0.9 132 | 133 | - Fix incorrect integer column values (only affecting some dbf files) 134 | - Add CSV export 135 | 136 | ## 1.0.8 137 | 138 | - Truncate column names on NULL 139 | - Fix schema dump for date and datetime columns 140 | - Replace internal helpers with ActiveSupport 141 | - Always underscore attribute names 142 | 143 | ## 1.0.7 144 | 145 | - Remove support for original column names. All columns names are now downcased/underscored. 146 | 147 | ## 1.0.6 148 | 149 | - DBF::Table now includes the Enumerable module 150 | - Return nil for memo values if the memo file is missing 151 | - Finder conditions now support the original and downcased/underscored column names 152 | 153 | ## 1.0.5 154 | 155 | - Strip non-ascii characters from column names 156 | 157 | ## 1.0.4 158 | 159 | - Underscore column names when dumping schemas (FieldId becomes field_id) 160 | 161 | ## 1.0.3 162 | 163 | - Add support for Visual Foxpro Integer and Datetime columns 164 | 165 | ## 1.0.2 166 | 167 | - Compatibility fix for Visual Foxpro memo files (ignore negative memo index values) 168 | 169 | ## 1.0.1 170 | 171 | - Fixes error when using the command-line interface [#11984] 172 | 173 | ## 1.0.0 174 | 175 | - Renamed classes and refactored code in preparation for adding the 176 | ability to save records and create/compact databases. 177 | - The Reader class has been renamed to Table 178 | - Attributes are no longer accessed directly from the record. Use record.attribute['column_name'] 179 | instead, or use the new attribute accessors detailed under Basic Usage. 180 | 181 | ## 0.5.4 182 | 183 | - Ignore deleted records in both memory modes 184 | 185 | ## 0.5.3 186 | 187 | - Added a standalone dbf utility (try dbf -h for help) 188 | 189 | ## 0.5.0 / 2007-05-25 190 | 191 | - New find method 192 | - Full compatibility with the two flavors of memo file 193 | - Two modes of operation: 194 | - In memory (default): All records are loaded into memory on the first 195 | request. Records are retrieved from memory for all subsequent requests. 196 | - File I/O: All records are retrieved from disk on every request 197 | - Improved documentation and more usage examples 198 | -------------------------------------------------------------------------------- /spec/dbf/file_formats_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | shared_examples_for 'DBF' do 4 | specify "sum of column lengths should equal record length specified in header plus one" do 5 | header_record_length = @table.instance_eval {@record_length} 6 | sum_of_column_lengths = @table.columns.inject(1) {|sum, column| sum += column.length} 7 | 8 | header_record_length.should == sum_of_column_lengths 9 | end 10 | 11 | specify "records should be instances of DBF::Record" do 12 | @table.all? {|record| record.should be_an_instance_of(DBF::Record)} 13 | end 14 | 15 | specify "record count should be the same as reported in the header" do 16 | @table.entries.size.should == @table.record_count 17 | end 18 | 19 | specify "column names should not be blank" do 20 | @table.columns.all? {|column| column.name.should_not be_empty} 21 | end 22 | 23 | specify "column types should be valid" do 24 | valid_column_types = %w(C N L D M F B G P Y T I V X @ O + 0) 25 | @table.columns.all? {|column| valid_column_types.should include(column.type)} 26 | end 27 | 28 | specify "column lengths should be instances of Fixnum" do 29 | @table.columns.all? {|column| column.length.should be_an_instance_of(Fixnum)} 30 | end 31 | 32 | specify "column lengths should be larger than 0" do 33 | @table.columns.all? {|column| column.length.should > 0} 34 | end 35 | 36 | specify "column decimals should be instances of Fixnum" do 37 | @table.columns.all? {|column| column.decimal.should be_an_instance_of(Fixnum)} 38 | end 39 | 40 | specify "column read accessors should return the attribute after typecast" do 41 | @table.columns do |column| 42 | record = @table.records.first 43 | record.send(column.name).should == record[column.name] 44 | end 45 | end 46 | 47 | specify "column attributes should be accessible in underscored form" do 48 | @table.columns do |column| 49 | record = @table.records.first 50 | record.send(column_name).should == record.send(Util.underscore(column_name)) 51 | end 52 | end 53 | end 54 | 55 | shared_examples_for 'Foxpro DBF' do 56 | specify "columns should be instances of DBF::FoxproColumn" do 57 | @table.columns.all? {|column| column.should be_an_instance_of(DBF::Column::Foxpro)} 58 | end 59 | end 60 | 61 | describe DBF, "of type 03 (dBase III without memo file)" do 62 | before do 63 | @table = DBF::Table.new "#{DB_PATH}/dbase_03.dbf" 64 | end 65 | 66 | it_should_behave_like "DBF" 67 | 68 | it "should report the correct version number" do 69 | @table.version.should == "03" 70 | end 71 | 72 | it "should report the correct version description" do 73 | @table.version_description.should == "dBase III without memo file" 74 | end 75 | 76 | it "should determine the number of records" do 77 | @table.record_count.should == 14 78 | end 79 | end 80 | 81 | describe DBF, "of type 30 (Visual FoxPro)" do 82 | before do 83 | @table = DBF::Table.new "#{DB_PATH}/dbase_30.dbf" 84 | end 85 | 86 | it_should_behave_like "DBF" 87 | 88 | it "should report the correct version number" do 89 | @table.version.should == "30" 90 | end 91 | 92 | it "should report the correct version description" do 93 | @table.version_description.should == "Visual FoxPro" 94 | end 95 | 96 | it "should determine the number of records" do 97 | @table.record_count.should == 34 98 | end 99 | end 100 | 101 | describe DBF, "of type 31 (Visual FoxPro with AutoIncrement field)" do 102 | before do 103 | @table = DBF::Table.new "#{DB_PATH}/dbase_31.dbf" 104 | end 105 | 106 | it_should_behave_like "DBF" 107 | 108 | it "should have a dBase version of 31" do 109 | @table.version.should == "31" 110 | end 111 | 112 | it "should report the correct version description" do 113 | @table.version_description.should == "Visual FoxPro with AutoIncrement field" 114 | end 115 | 116 | it "should determine the number of records" do 117 | @table.record_count.should == 77 118 | end 119 | end 120 | 121 | describe DBF, "of type 83 (dBase III with memo file)" do 122 | before do 123 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 124 | end 125 | 126 | it_should_behave_like "DBF" 127 | 128 | it "should report the correct version number" do 129 | @table.version.should == "83" 130 | end 131 | 132 | it "should report the correct version description" do 133 | @table.version_description.should == "dBase III with memo file" 134 | end 135 | 136 | it "should determine the number of records" do 137 | @table.record_count.should == 67 138 | end 139 | end 140 | 141 | describe DBF, "of type 8b (dBase IV with memo file)" do 142 | before do 143 | @table = DBF::Table.new "#{DB_PATH}/dbase_8b.dbf" 144 | end 145 | 146 | it_should_behave_like "DBF" 147 | 148 | it "should report the correct version number" do 149 | @table.version.should == "8b" 150 | end 151 | 152 | it "should report the correct version description" do 153 | @table.version_description.should == "dBase IV with memo file" 154 | end 155 | 156 | it "should determine the number of records" do 157 | @table.record_count.should == 10 158 | end 159 | end 160 | 161 | describe DBF, "of type f5 (FoxPro with memo file)" do 162 | before do 163 | @table = DBF::Table.new "#{DB_PATH}/dbase_f5.dbf" 164 | end 165 | 166 | it_should_behave_like "DBF" 167 | it_should_behave_like "Foxpro DBF" 168 | 169 | it "should report the correct version number" do 170 | @table.version.should == "f5" 171 | end 172 | 173 | it "should report the correct version description" do 174 | @table.version_description.should == "FoxPro with memo file" 175 | end 176 | 177 | it "should determine the number of records" do 178 | @table.record_count.should == 975 179 | end 180 | end -------------------------------------------------------------------------------- /spec/dbf/table_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe DBF::Table do 4 | specify 'foxpro versions' do 5 | DBF::Table::FOXPRO_VERSIONS.keys.sort.should == %w(30 31 f5 fb).sort 6 | end 7 | 8 | describe '#initialize' do 9 | it 'should accept a DBF filename' do 10 | expect { DBF::Table.new "#{DB_PATH}/dbase_83.dbf" }.to_not raise_error 11 | end 12 | 13 | it 'should accept a DBF and Memo filename' do 14 | expect { DBF::Table.new "#{DB_PATH}/dbase_83.dbf", "#{DB_PATH}/dbase_83.dbt" }.to_not raise_error 15 | end 16 | 17 | it 'should accept an io-like data object' do 18 | data = StringIO.new File.read("#{DB_PATH}/dbase_83.dbf") 19 | expect { DBF::Table.new data }.to_not raise_error 20 | end 21 | 22 | it 'should accept an io-like data and memo object' do 23 | data = StringIO.new File.read("#{DB_PATH}/dbase_83.dbf") 24 | memo = StringIO.new File.read("#{DB_PATH}/dbase_83.dbt") 25 | expect { DBF::Table.new data, memo }.to_not raise_error 26 | end 27 | end 28 | 29 | context "when closed" do 30 | before do 31 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 32 | @table.close 33 | end 34 | 35 | it "should close the data file" do 36 | @table.instance_eval { @data }.should be_closed 37 | end 38 | 39 | it "should close the memo file" do 40 | @table.instance_eval { @memo }.instance_eval { @data }.should be_closed 41 | end 42 | end 43 | 44 | describe "#schema" do 45 | it "should match the test schema fixture" do 46 | table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 47 | control_schema = File.read("#{DB_PATH}/dbase_83_schema.txt") 48 | table.schema.should == control_schema 49 | end 50 | end 51 | 52 | describe '#to_csv' do 53 | let(:table) { DBF::Table.new "#{DB_PATH}/dbase_83.dbf" } 54 | 55 | after do 56 | FileUtils.rm_f 'test.csv' 57 | end 58 | 59 | describe 'when no path param passed' do 60 | it 'should dump to STDOUT' do 61 | begin 62 | $stdout = StringIO.new 63 | table.to_csv 64 | $stdout.string.should_not be_empty 65 | ensure 66 | $stdout = STDOUT 67 | end 68 | end 69 | end 70 | 71 | describe 'when path param passed' do 72 | it 'should create custom csv file' do 73 | table.to_csv('test.csv') 74 | File.exists?('test.csv').should be_true 75 | end 76 | end 77 | end 78 | 79 | describe "#record" do 80 | before do 81 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 82 | end 83 | 84 | it "return nil for deleted records" do 85 | @table.stub!(:deleted_record?).and_return(true) 86 | @table.record(5).should be_nil 87 | end 88 | end 89 | 90 | describe "#current_record" do 91 | before do 92 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 93 | end 94 | 95 | it "should return nil for deleted records" do 96 | @table.stub!(:deleted_record?).and_return(true) 97 | @table.record(0).should be_nil 98 | end 99 | end 100 | 101 | describe "#find" do 102 | describe "with index" do 103 | before do 104 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 105 | end 106 | 107 | it "should return the correct record" do 108 | @table.find(5).should == @table.record(5) 109 | end 110 | end 111 | 112 | describe 'with array of indexes' do 113 | before do 114 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 115 | end 116 | 117 | it "should return the correct records" do 118 | @table.find([1, 5, 10]).should == [@table.record(1), @table.record(5), @table.record(10)] 119 | end 120 | end 121 | 122 | describe "with :all" do 123 | before do 124 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 125 | end 126 | 127 | it "should accept a block" do 128 | records = [] 129 | @table.find(:all, :weight => 0.0) do |record| 130 | records << record 131 | end 132 | records.should == @table.find(:all, :weight => 0.0) 133 | end 134 | 135 | it "should return all records if options are empty" do 136 | @table.find(:all).should == @table.to_a 137 | end 138 | 139 | it "should return matching records when used with options" do 140 | @table.find(:all, "WEIGHT" => 0.0).should == @table.select {|r| r.attributes["weight"] == 0.0} 141 | end 142 | 143 | it "should AND multiple search terms" do 144 | @table.find(:all, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should == [] 145 | end 146 | 147 | it "should match original column names" do 148 | @table.find(:all, "WEIGHT" => 0.0).should_not be_empty 149 | end 150 | 151 | it "should match symbolized column names" do 152 | @table.find(:all, :WEIGHT => 0.0).should_not be_empty 153 | end 154 | 155 | it "should match downcased column names" do 156 | @table.find(:all, "weight" => 0.0).should_not be_empty 157 | end 158 | 159 | it "should match symbolized downcased column names" do 160 | @table.find(:all, :weight => 0.0).should_not be_empty 161 | end 162 | end 163 | 164 | describe "with :first" do 165 | before do 166 | @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf" 167 | end 168 | 169 | it "should return the first record if options are empty" do 170 | @table.find(:first).should == @table.record(0) 171 | end 172 | 173 | it "should return the first matching record when used with options" do 174 | @table.find(:first, "CODE" => "C").should == @table.record(5) 175 | end 176 | 177 | it "should AND multiple search terms" do 178 | @table.find(:first, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should be_nil 179 | end 180 | end 181 | end 182 | 183 | describe "filename" do 184 | before do 185 | @table = DBF::Table.new "#{DB_PATH}/dbase_03.dbf" 186 | end 187 | 188 | it 'should be dbase_03.dbf' do 189 | @table.filename.should == "dbase_03.dbf" 190 | end 191 | end 192 | 193 | describe 'has_memo_file?' do 194 | describe 'without a memo file' do 195 | let(:table) { DBF::Table.new "#{DB_PATH}/dbase_03.dbf" } 196 | specify { table.has_memo_file?.should be_false } 197 | end 198 | 199 | describe 'with a memo file' do 200 | let(:table) { DBF::Table.new "#{DB_PATH}/dbase_30.dbf" } 201 | specify { table.has_memo_file?.should be_true } 202 | end 203 | end 204 | 205 | describe 'columns' do 206 | before do 207 | @table = DBF::Table.new "#{DB_PATH}/dbase_03.dbf" 208 | end 209 | 210 | it 'should have correct size' do 211 | @table.columns.size.should == 31 212 | end 213 | 214 | it 'should have correct names' do 215 | @table.columns.first.name.should == 'Point_ID' 216 | @table.columns[29].name.should == 'Easting' 217 | end 218 | end 219 | end 220 | 221 | -------------------------------------------------------------------------------- /spec/dbf/column_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe DBF::Column::Dbase do 4 | 5 | context "when initialized" do 6 | let(:column) { DBF::Column::Dbase.new "ColumnName", "N", 1, 0, "30" } 7 | 8 | it "sets the #name accessor" do 9 | column.name.should == "ColumnName" 10 | end 11 | 12 | it "sets the #type accessor" do 13 | column.type.should == "N" 14 | end 15 | 16 | it "sets the #length accessor" do 17 | column.length.should == 1 18 | end 19 | 20 | it "sets the #decimal accessor" do 21 | column.decimal.should == 0 22 | end 23 | 24 | describe 'with length of 0' do 25 | specify { lambda { DBF::Column::Dbase.new "ColumnName", "N", 0, 0, "30" }.should raise_error(DBF::Column::LengthError) } 26 | end 27 | 28 | describe 'with length less than 0' do 29 | specify { lambda { DBF::Column::Dbase.new "ColumnName", "N", -1, 0, "30" }.should raise_error(DBF::Column::LengthError) } 30 | end 31 | 32 | describe 'with empty column name' do 33 | specify { lambda { DBF::Column::Dbase.new "\xFF\xFC", "N", 1, 0, "30" }.should raise_error(DBF::Column::NameError) } 34 | end 35 | end 36 | 37 | context '#type_cast' do 38 | context 'with type N (number)' do 39 | context 'and 0 decimals' do 40 | it 'casts value to Fixnum' do 41 | value = '135' 42 | column = DBF::Column::Dbase.new "ColumnName", "N", 3, 0, "30" 43 | column.type_cast(value).should be_a(Fixnum) 44 | column.type_cast(value).should == 135 45 | end 46 | end 47 | 48 | context 'and more than 0 decimals' do 49 | it 'casts value to Float' do 50 | value = '13.5' 51 | column = DBF::Column::Dbase.new "ColumnName", "N", 2, 1, "30" 52 | column.type_cast(value).should be_a(Float) 53 | column.type_cast(value).should == 13.5 54 | end 55 | end 56 | end 57 | 58 | context 'with type F (float)' do 59 | it 'casts value to Float' do 60 | value = '135' 61 | column = DBF::Column::Dbase.new "ColumnName", "F", 3, 0, "30" 62 | column.type_cast(value).should be_a(Float) 63 | column.type_cast(value).should == 135.0 64 | end 65 | end 66 | 67 | context 'with type I (integer)' do 68 | it "casts value to Fixnum" do 69 | value = "\203\171\001\000" 70 | column = DBF::Column::Dbase.new "ColumnName", "I", 3, 0, "30" 71 | column.type_cast(value).should == 96643 72 | end 73 | end 74 | 75 | context 'with type L (logical/boolean)' do 76 | let(:column) { DBF::Column::Dbase.new "ColumnName", "L", 1, 0, "30" } 77 | 78 | it "casts 'y' to true" do 79 | column.type_cast('y').should == true 80 | end 81 | 82 | it "casts 't' to true" do 83 | column.type_cast('t').should == true 84 | end 85 | 86 | it "casts value other than 't' or 'y' to false" do 87 | column.type_cast('n').should == false 88 | end 89 | end 90 | 91 | context 'with type T (datetime)' do 92 | let(:column) { DBF::Column::Dbase.new "ColumnName", "T", 16, 0, "30" } 93 | 94 | context 'with valid datetime' do 95 | it "casts to DateTime" do 96 | column.type_cast("Nl%\000\300Z\252\003").should == DateTime.parse("2002-10-10T17:04:56+00:00") 97 | end 98 | end 99 | 100 | context 'when requiring mathn' do 101 | it "casts to DateTime" do 102 | lambda do 103 | require 'mathn' 104 | column.type_cast("Nl%\000\300Z\252\003") 105 | end.call.should == DateTime.parse("2002-10-10T17:04:56+00:00") 106 | end 107 | end 108 | 109 | context 'with invalid datetime' do 110 | it "casts to nil" do 111 | column.type_cast("Nl%\000\000A\000\999").should be_nil 112 | end 113 | end 114 | end 115 | 116 | context 'with type D (date)' do 117 | let(:column) { DBF::Column::Dbase.new "ColumnName", "D", 8, 0, "30" } 118 | 119 | context 'with valid date' do 120 | it "casts to Date" do 121 | column.type_cast("20050712").should == Date.new(2005,7,12) 122 | end 123 | end 124 | 125 | context 'with invalid date' do 126 | it "casts to nil" do 127 | column.type_cast("0").should be_nil 128 | end 129 | end 130 | end 131 | 132 | context 'with type M (memo)' do 133 | it "casts to string" do 134 | column = DBF::Column::Dbase.new "ColumnName", "M", 3, 0, "30" 135 | column.type_cast('abc').should be_a(String) 136 | end 137 | end 138 | end 139 | 140 | context 'with type Y (currency)' do 141 | let(:column) { DBF::Column::Dbase.new "ColumnName", "Y", 8, 4, "31" } 142 | 143 | it 'casts to float' do 144 | column.type_cast(" \xBF\x02\x00\x00\x00\x00\x00").should == 18.0 145 | end 146 | end 147 | 148 | context "#schema_definition" do 149 | context 'with type N (number)' do 150 | it "outputs an integer column" do 151 | column = DBF::Column::Dbase.new "ColumnName", "N", 1, 0, "30" 152 | column.schema_definition.should == "\"column_name\", :integer\n" 153 | end 154 | end 155 | 156 | context "with type B (binary)" do 157 | context "with Foxpro dbf" do 158 | context "when decimal is greater than 0" do 159 | it "outputs an float column" do 160 | column = DBF::Column::Dbase.new "ColumnName", "B", 1, 2, "f5" 161 | column.schema_definition.should == "\"column_name\", :float\n" 162 | end 163 | end 164 | 165 | context "when decimal is 0" do 166 | column = DBF::Column::Dbase.new "ColumnName", "B", 1, 0, "f5" 167 | column.schema_definition.should == "\"column_name\", :integer\n" 168 | end 169 | end 170 | 171 | context "when non-Foxpro dbf" do 172 | it "outputs a text column" do 173 | 174 | end 175 | end 176 | end 177 | 178 | it "defines a float colmn if type is (N)umber with more than 0 decimals" do 179 | column = DBF::Column::Dbase.new "ColumnName", "N", 1, 2, "30" 180 | column.schema_definition.should == "\"column_name\", :float\n" 181 | end 182 | 183 | it "defines a date column if type is (D)ate" do 184 | column = DBF::Column::Dbase.new "ColumnName", "D", 8, 0, "30" 185 | column.schema_definition.should == "\"column_name\", :date\n" 186 | end 187 | 188 | it "defines a datetime column if type is (D)ate" do 189 | column = DBF::Column::Dbase.new "ColumnName", "T", 16, 0, "30" 190 | column.schema_definition.should == "\"column_name\", :datetime\n" 191 | end 192 | 193 | it "defines a boolean column if type is (L)ogical" do 194 | column = DBF::Column::Dbase.new "ColumnName", "L", 1, 0, "30" 195 | column.schema_definition.should == "\"column_name\", :boolean\n" 196 | end 197 | 198 | it "defines a text column if type is (M)emo" do 199 | column = DBF::Column::Dbase.new "ColumnName", "M", 1, 0, "30" 200 | column.schema_definition.should == "\"column_name\", :text\n" 201 | end 202 | 203 | it "defines a string column with length for any other data types" do 204 | column = DBF::Column::Dbase.new "ColumnName", "X", 20, 0, "30" 205 | column.schema_definition.should == "\"column_name\", :string, :limit => 20\n" 206 | end 207 | end 208 | 209 | context "#name" do 210 | it "contains only ASCII characters" do 211 | column = DBF::Column::Dbase.new "--\x1F-\x68\x65\x6C\x6C\x6F world-\x80--", "N", 1, 0, "30" 212 | column.name.should == "---hello world---" 213 | end 214 | 215 | it "is truncated at the null character" do 216 | column = DBF::Column::Dbase.new "--\x1F-\x68\x65\x6C\x6C\x6F \x00 world-\x80--", "N", 1, 0, "30" 217 | column.name.should == "---hello " 218 | end 219 | end 220 | 221 | context '#decode_date' do 222 | let(:column) { DBF::Column::Dbase.new "ColumnName", "N", 1, 0, "30" } 223 | 224 | it 'is nil if value is blank' do 225 | column.send(:decode_date, '').should be_nil 226 | end 227 | 228 | it 'interperets spaces as zeros' do 229 | column.send(:decode_date, '2010 715').should == Date.parse('20100715') 230 | end 231 | end 232 | 233 | end 234 | -------------------------------------------------------------------------------- /spec/fixtures/dbase_03.dbf: -------------------------------------------------------------------------------- 1 |  NPoint_IDC TypeCShapeCCircular_DCNon_circulC<Flow_preseCConditionCCommentsC<Date_VisitDTimeC 2 | Max_PDOPNMax_HDOPNCorr_TypeC$Rcvr_TypeC$GPS_DateDGPS_TimeC 3 | Update_StaC$Feat_NameCDatafileCUnfilt_PosN 4 | Filt_PosN 5 | Data_DictiCGPS_WeekNGPS_SecondN GPS_HeightNVert_PrecNHorz_PrecNStd_DevNNorthingNEastingNPoint_IDN 0507121 CMP circular 12 no Good 2005071210:56:30am 5.2 2.0Postprocessed Code GeoXT 2005071210:56:52amNew Driveway 050712TR2819.cor 2 2MS4 1331 226625.000 1131.323 3.1 1.3 0.897088 557904.898 2212577.192 401 0507122 CMP circular 12 no Good 2005071210:57:34am 4.9 2.0Postprocessed Code GeoXT 2005071210:57:37amNew Driveway 050712TR2819.cor 1 1MS4 1331 226670.000 1125.142 2.8 1.3 557997.831 2212576.868 402 0507123 CMP circular 12 no Good 2005071210:59:03am 5.4 4.4Postprocessed Code GeoXT 2005071210:59:12amNew Driveway 050712TR2819.cor 1 1MS4 1331 226765.000 1127.570 2.2 3.5 558184.757 2212571.349 403 0507125 CMP circular 12 no Good 2005071211:02:43am 3.4 1.5Postprocessed Code GeoXT 2005071211:03:12amNew Driveway 050712TR2819.cor 1 1MS4 1331 227005.000 1125.364 3.2 1.6 558703.723 2212562.547 405 05071210 CMP circular 15 no Good 2005071211:15:20am 3.7 2.2Postprocessed Code GeoXT 2005071211:14:52amNew Driveway 050712TR2819.cor 1 1MS4 1331 227705.000 1118.605 1.8 2.1 558945.763 2212739.979 410 05071216 CMP circular 12 no Good 2005071212:13:23pm 4.4 1.8Postprocessed Code GeoXT 2005071212:13:57pmNew Driveway 050712TR2819.cor 1 1MS4 1331 231250.000 1117.390 3.1 1.2 559024.234 2212856.927 416 05071217 CMP circular 12 no Good 2005071212:16:46pm 4.4 1.8Postprocessed Code GeoXT 2005071212:17:12pmNew Driveway 050712TR2819.cor 1 1MS4 1331 231445.000 1125.714 3.2 1.3 559342.534 2213340.161 417 05071219 CMP circular 12 no Plugged 2005071212:22:55pm 4.4 1.8Postprocessed Code GeoXT 2005071212:22:22pmNew Driveway 050712TR2819.cor 1 1MS4 1331 231755.000 1110.786 2.5 1.1 559578.776 2213560.247 419 05071224 CMP circular 12 no Good 2005071212:37:17pm 4.1 1.7Postprocessed Code GeoXT 2005071212:38:32pmNew Driveway 050712TR2819.cor 1 1MS4 1331 232725.000 1077.924 2.8 1.4 560582.575 2213759.022 424 05071225 CMP circular 12 no Good 2005071212:39:48pm 4.0 1.7Postprocessed Code GeoXT 2005071212:39:52pmNew Driveway 050712TR2819.cor 1 1MS4 1331 232805.000 1082.990 2.0 1.0 560678.501 2213716.657 425 05071229 CMP circular 12 no Good 2005071212:49:05pm 3.7 1.7Postprocessed Code GeoXT 2005071212:49:07pmNew Driveway 050712TR2819.cor 1 1MS4 1331 233360.000 1096.860 2.4 1.2 560126.094 2213720.301 429 05071231 CMP circular 12 no Plugged 2005071212:53:58pm 3.0 1.6Postprocessed Code GeoXT 2005071212:54:02pmNew Driveway 050712TR2819.cor 1 1MS4 1331 233655.000 1105.113 1.8 1.1 559952.331 2213689.001 431 05071232 CMP circular 12 no Plugged 2005071212:55:47pm 3.5 1.7Postprocessed Code GeoXT 2005071212:55:47pmNew Driveway 050712TR2819.cor 2 2MS4 1331 233760.000 1101.939 2.1 1.1 1.223112 559870.352 2213661.918 432 05071236 CMP circular 12 no Plugged 2005071201:08:40pm 3.3 1.6Postprocessed Code GeoXT 2005071201:08:42pmNew Driveway 050712TR2819.cor 1 1MS4 1331 234535.000 1125.517 1.8 1.2 559195.031 2213046.199 436 -------------------------------------------------------------------------------- /lib/dbf/table.rb: -------------------------------------------------------------------------------- 1 | module DBF 2 | 3 | # DBF::Table is the primary interface to a single DBF file and provides 4 | # methods for enumerating and searching the records. 5 | class Table 6 | include Enumerable 7 | 8 | DBF_HEADER_SIZE = 32 9 | 10 | VERSIONS = { 11 | "02" => "FoxBase", 12 | "03" => "dBase III without memo file", 13 | "04" => "dBase IV without memo file", 14 | "05" => "dBase V without memo file", 15 | "07" => "Visual Objects 1.x", 16 | "30" => "Visual FoxPro", 17 | "31" => "Visual FoxPro with AutoIncrement field", 18 | "7b" => "dBase IV with memo file", 19 | "83" => "dBase III with memo file", 20 | "87" => "Visual Objects 1.x with memo file", 21 | "8b" => "dBase IV with memo file", 22 | "8e" => "dBase IV with SQL table", 23 | "f5" => "FoxPro with memo file", 24 | "fb" => "FoxPro without memo file" 25 | } 26 | 27 | FOXPRO_VERSIONS = { 28 | "30" => "Visual FoxPro", 29 | "31" => "Visual FoxPro with AutoIncrement field", 30 | "f5" => "FoxPro with memo file", 31 | "fb" => "FoxPro without memo file" 32 | } 33 | 34 | attr_reader :version # Internal dBase version number 35 | attr_reader :record_count # Total number of records 36 | attr_accessor :encoding # Source encoding (for ex. :cp1251) 37 | 38 | # Opens a DBF::Table 39 | # Examples: 40 | # # working with a file stored on the filesystem 41 | # table = DBF::Table.new 'data.dbf' 42 | # 43 | # # working with a misnamed memo file 44 | # table = DBF::Table.new 'data.dbf', 'memo.dbt' 45 | # 46 | # # working with a dbf in memory 47 | # table = DBF::Table.new StringIO.new(dbf_data) 48 | # 49 | # # working with a dbf and memo in memory 50 | # table = DBF::Table.new StringIO.new(dbf_data), StringIO.new(memo_data) 51 | # 52 | # @param [String, StringIO] data Path to the dbf file or a StringIO object 53 | # @param [optional String, StringIO] memo Path to the memo file or a StringIO object 54 | def initialize(data, memo = nil) 55 | @data = open_data(data) 56 | get_header_info 57 | @memo = open_memo(data, memo) 58 | end 59 | 60 | # @return [TrueClass, FalseClass] 61 | def has_memo_file? 62 | !!@memo 63 | end 64 | 65 | # Closes the table and memo file 66 | # 67 | # @return [TrueClass, FalseClass] 68 | def close 69 | @memo && @memo.close 70 | @data.close && @data.closed? 71 | end 72 | 73 | # @return String 74 | def filename 75 | File.basename @data.path 76 | end 77 | 78 | # Calls block once for each record in the table. The record may be nil 79 | # if the record has been marked as deleted. 80 | # 81 | # @yield [nil, DBF::Record] 82 | def each 83 | @record_count.times {|i| yield record(i)} 84 | end 85 | 86 | # Retrieve a record by index number. 87 | # The record will be nil if it has been deleted, but not yet pruned from 88 | # the database. 89 | # 90 | # @param [Fixnum] index 91 | # @return [DBF::Record, NilClass] 92 | def record(index) 93 | seek(index * @record_length) 94 | if !deleted_record? 95 | DBF::Record.new(@data.read(@record_length), columns, version, @memo) 96 | end 97 | end 98 | 99 | alias_method :row, :record 100 | 101 | # Human readable version description 102 | # 103 | # @return [String] 104 | def version_description 105 | VERSIONS[version] 106 | end 107 | 108 | # Generate an ActiveRecord::Schema 109 | # 110 | # xBase data types are converted to generic types as follows: 111 | # - Number columns with no decimals are converted to :integer 112 | # - Number columns with decimals are converted to :float 113 | # - Date columns are converted to :datetime 114 | # - Logical columns are converted to :boolean 115 | # - Memo columns are converted to :text 116 | # - Character columns are converted to :string and the :limit option is set 117 | # to the length of the character column 118 | # 119 | # Example: 120 | # create_table "mydata" do |t| 121 | # t.column :name, :string, :limit => 30 122 | # t.column :last_update, :datetime 123 | # t.column :is_active, :boolean 124 | # t.column :age, :integer 125 | # t.column :notes, :text 126 | # end 127 | # 128 | # @return [String] 129 | def schema 130 | s = "ActiveRecord::Schema.define do\n" 131 | s << " create_table \"#{File.basename(@data.path, ".*")}\" do |t|\n" 132 | columns.each do |column| 133 | s << " t.column #{column.schema_definition}" 134 | end 135 | s << " end\nend" 136 | s 137 | end 138 | 139 | # Dumps all records to a CSV file. If no filename is given then CSV is 140 | # output to STDOUT. 141 | # 142 | # @param [optional String] path Defaults to STDOUT 143 | def to_csv(path = nil) 144 | csv = csv_class.new((path ? File.open(path, 'w') : $stdout), :force_quotes => true) 145 | csv << columns.map {|c| c.name} 146 | each {|record| csv << record.to_a} 147 | end 148 | 149 | # Find records using a simple ActiveRecord-like syntax. 150 | # 151 | # Examples: 152 | # table = DBF::Table.new 'mydata.dbf' 153 | # 154 | # # Find record number 5 155 | # table.find(5) 156 | # 157 | # # Find all records for Keith Morrison 158 | # table.find :all, :first_name => "Keith", :last_name => "Morrison" 159 | # 160 | # # Find first record 161 | # table.find :first, :first_name => "Keith" 162 | # 163 | # The command may be a record index, :all, or :first. 164 | # options is optional and, if specified, should be a hash where the keys correspond 165 | # to column names in the database. The values will be matched exactly with the value 166 | # in the database. If you specify more than one key, all values must match in order 167 | # for the record to be returned. The equivalent SQL would be "WHERE key1 = 'value1' 168 | # AND key2 = 'value2'". 169 | # 170 | # @param [Fixnum, Symbol] command 171 | # @param [optional, Hash] options Hash of search parameters 172 | # @yield [optional, DBF::Record, NilClass] 173 | def find(command, options = {}, &block) 174 | case command 175 | when Fixnum 176 | record(command) 177 | when Array 178 | command.map {|i| record(i)} 179 | when :all 180 | find_all(options, &block) 181 | when :first 182 | find_first(options) 183 | end 184 | end 185 | 186 | # Retrieves column information from the database 187 | def columns 188 | @columns ||= begin 189 | @data.seek(DBF_HEADER_SIZE) 190 | columns = [] 191 | while !["\0", "\r"].include?(first_byte = @data.read(1)) 192 | column_data = first_byte + @data.read(31) 193 | name, type, length, decimal = column_data.unpack('a10 x a x4 C2') 194 | if length > 0 195 | columns << column_class.new(name.strip, type, length, decimal, version, @encoding) 196 | end 197 | end 198 | columns 199 | end 200 | end 201 | 202 | def supports_encoding? 203 | String.new.respond_to? :encoding 204 | end 205 | 206 | def foxpro? 207 | FOXPRO_VERSIONS.keys.include? @version 208 | end 209 | 210 | private 211 | 212 | def column_class #nodoc 213 | @column_class ||= if foxpro? 214 | Column::Foxpro 215 | else 216 | Column::Dbase 217 | end 218 | end 219 | 220 | def memo_class #nodoc 221 | @memo_class ||= if foxpro? 222 | Memo::Foxpro 223 | else 224 | if @version == "83" 225 | Memo::Dbase3 226 | else 227 | Memo::Dbase4 228 | end 229 | end 230 | end 231 | 232 | def column_count #nodoc 233 | @column_count ||= ((@header_length - DBF_HEADER_SIZE + 1) / DBF_HEADER_SIZE).to_i 234 | end 235 | 236 | def open_data(data) #nodoc 237 | data.is_a?(StringIO) ? data : File.open(data, 'rb') 238 | end 239 | 240 | def open_memo(data, memo = nil) #nodoc 241 | if memo.is_a? StringIO 242 | memo_class.new(memo, version) 243 | elsif memo 244 | memo_class.open(memo, version) 245 | elsif !data.is_a? StringIO 246 | dirname = File.dirname(data) 247 | basename = File.basename(data, '.*') 248 | files = Dir.glob("#{dirname}/#{basename}*.{fpt,FPT,dbt,DBT}") 249 | files.any? ? memo_class.open(files.first, version) : nil 250 | else 251 | nil 252 | end 253 | end 254 | 255 | def find_all(options) #nodoc 256 | map do |record| 257 | if record && record.match?(options) 258 | yield record if block_given? 259 | record 260 | end 261 | end.compact 262 | end 263 | 264 | def find_first(options) #nodoc 265 | detect {|record| record && record.match?(options)} 266 | end 267 | 268 | def deleted_record? #nodoc 269 | @data.read(1).unpack('a') == ['*'] 270 | end 271 | 272 | def get_header_info #nodoc 273 | @data.rewind 274 | @version, @record_count, @header_length, @record_length, @encoding_key = read_header 275 | @encoding = self.class.encodings[@encoding_key] if supports_encoding? 276 | end 277 | 278 | def read_header #nodoc 279 | @data.read(DBF_HEADER_SIZE).unpack("H2 x3 V v2 x17H2") 280 | end 281 | 282 | def seek(offset) #nodoc 283 | @data.seek @header_length + offset 284 | end 285 | 286 | def csv_class #nodoc 287 | @csv_class ||= CSV.const_defined?(:Reader) ? FCSV : CSV 288 | end 289 | 290 | def self.encodings #nodoc 291 | @encodings ||= YAML.load_file File.expand_path("../encodings.yml", __FILE__) 292 | end 293 | end 294 | 295 | end 296 | --------------------------------------------------------------------------------