dbf 1.0.11 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +5 -2
- data/README.markdown +37 -16
- data/Rakefile +7 -0
- data/VERSION.yml +2 -2
- data/dbf.gemspec +2 -2
- data/lib/dbf/column.rb +38 -1
- data/lib/dbf/record.rb +39 -6
- data/lib/dbf/table.rb +192 -140
- data/spec/functional/dbf_shared.rb +3 -1
- data/spec/spec_helper.rb +6 -5
- data/spec/unit/record_spec.rb +11 -11
- data/spec/unit/table_spec.rb +112 -80
- metadata +2 -2
data/History.txt
CHANGED
@@ -1,10 +1,13 @@
|
|
1
|
+
== 1.0.13
|
2
|
+
* Allow passing an array of ids to find
|
3
|
+
|
1
4
|
== 1.0.11
|
2
5
|
|
3
6
|
* Attributes are now accessible by original or underscored name
|
4
7
|
|
5
|
-
== 1.0
|
8
|
+
== 1.1.0
|
6
9
|
|
7
|
-
*
|
10
|
+
* Add support for large table that will not fit into memory
|
8
11
|
|
9
12
|
== 1.0.9
|
10
13
|
|
data/README.markdown
CHANGED
@@ -11,7 +11,9 @@ DBF is a small fast library for reading dBase, xBase, Clipper and FoxPro databas
|
|
11
11
|
|
12
12
|
* No external dependencies
|
13
13
|
* Fields are type cast to the appropriate Ruby types
|
14
|
+
* ActiveRecord like finder methods
|
14
15
|
* Ability to dump the database schema in the portable ActiveRecord::Schema format
|
16
|
+
* Ruby 1.9.1 compatible
|
15
17
|
|
16
18
|
## Installation
|
17
19
|
|
@@ -19,32 +21,51 @@ DBF is a small fast library for reading dBase, xBase, Clipper and FoxPro databas
|
|
19
21
|
|
20
22
|
## Basic Usage
|
21
23
|
|
24
|
+
Load a DBF file:
|
25
|
+
|
22
26
|
require 'rubygems'
|
23
27
|
require 'dbf'
|
24
28
|
|
25
29
|
table = DBF::Table.new("widgets.dbf")
|
26
30
|
|
27
|
-
|
28
|
-
widget_ids = table.map { |row| row.id }
|
29
|
-
abc_names = table.select { |row| row.name =~ /^[a-cA-C]/ }
|
30
|
-
sorted = table.sort_by { |row| row.name }
|
31
|
-
|
32
|
-
# Print the 'name' field from record number 4
|
33
|
-
puts table.record(4).name
|
31
|
+
Enumerate all records
|
34
32
|
|
35
|
-
# Attributes can also be accessed using the column name as a Hash key
|
36
|
-
puts table.record(4).attributes["name"]
|
37
|
-
|
38
|
-
# Print the 'name' and 'address' fields from each record
|
39
33
|
table.records.each do |record|
|
40
34
|
puts record.name
|
41
35
|
puts record.email
|
42
36
|
end
|
37
|
+
|
38
|
+
Load a single record using <tt>records</tt> or <tt>find</tt>
|
39
|
+
|
40
|
+
table.records(6)
|
41
|
+
table.find(6)
|
43
42
|
|
44
|
-
|
45
|
-
|
46
|
-
|
43
|
+
Attributes can also be accessed through the attributes hash in original or
|
44
|
+
underscored form or as an accessor method using the underscored name.
|
45
|
+
|
46
|
+
table.record(4).attributes["PhoneBook"]
|
47
|
+
table.record(4).attributes["phone_book"]
|
48
|
+
table.record(4).phone_book
|
49
|
+
|
50
|
+
Search for records using a simple hash format. Multiple search criteria are
|
51
|
+
ANDed. Use the block form of find if the resulting recordset could be large
|
52
|
+
otherwise all records will be loaded into memory.
|
53
|
+
|
54
|
+
# find all records with first_name equal to Keith
|
55
|
+
table.find(:all, :first_name => 'Keith') do |record|
|
56
|
+
puts record.last_name
|
57
|
+
end
|
58
|
+
|
59
|
+
# find all records with first_name equal to Keith and last_name equal
|
60
|
+
# to Morrison
|
61
|
+
table.find(:all, :first_name => 'Keith', :last_name => 'Morrison') do |record|
|
62
|
+
puts record.last_name
|
63
|
+
end
|
64
|
+
|
65
|
+
# find the first record with first_name equal to Keith
|
47
66
|
table.find :first, :first_name => 'Keith'
|
67
|
+
|
68
|
+
# find record number 10
|
48
69
|
table.find(10)
|
49
70
|
|
50
71
|
## Migrating to ActiveRecord
|
@@ -58,7 +79,7 @@ An example of migrating a DBF book table to ActiveRecord using a migration:
|
|
58
79
|
table = DBF::Table.new('db/dbf/books.dbf')
|
59
80
|
eval(table.schema)
|
60
81
|
|
61
|
-
table.
|
82
|
+
table.each do |record|
|
62
83
|
Book.create(record.attributes)
|
63
84
|
end
|
64
85
|
end
|
@@ -81,7 +102,7 @@ A small command-line utility called dbf is installed along with the gem.
|
|
81
102
|
## Limitations and known bugs
|
82
103
|
|
83
104
|
* DBF is read-only
|
84
|
-
*
|
105
|
+
* External index files are not used
|
85
106
|
|
86
107
|
## License
|
87
108
|
|
data/Rakefile
CHANGED
@@ -27,6 +27,13 @@ Spec::Rake::SpecTask.new :spec do |t|
|
|
27
27
|
t.spec_files = FileList['spec/**/*spec.rb']
|
28
28
|
end
|
29
29
|
|
30
|
+
desc "Run rcov"
|
31
|
+
Spec::Rake::SpecTask.new :rcov do |t|
|
32
|
+
t.spec_files = FileList['spec/**/*spec.rb']
|
33
|
+
t.rcov = true
|
34
|
+
t.rcov_opts = ["--exclude ~\/.gem"]
|
35
|
+
end
|
36
|
+
|
30
37
|
desc "Run spec docs"
|
31
38
|
Spec::Rake::SpecTask.new :specdoc do |t|
|
32
39
|
t.spec_opts = ["-f specdoc"]
|
data/VERSION.yml
CHANGED
data/dbf.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{dbf}
|
8
|
-
s.version = "1.0
|
8
|
+
s.version = "1.1.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Keith Morrison"]
|
12
|
-
s.date = %q{2009-12-
|
12
|
+
s.date = %q{2009-12-14}
|
13
13
|
s.default_executable = %q{dbf}
|
14
14
|
s.description = %q{A small fast library for reading dBase, xBase, Clipper and FoxPro database files.}
|
15
15
|
s.email = %q{keithm@infused.org}
|
data/lib/dbf/column.rb
CHANGED
@@ -2,9 +2,17 @@ module DBF
|
|
2
2
|
class ColumnLengthError < DBFError; end
|
3
3
|
class ColumnNameError < DBFError; end
|
4
4
|
|
5
|
+
# DBF::Column stores all the information about a column including its name,
|
6
|
+
# type, length and number of decimal places (if any)
|
5
7
|
class Column
|
6
8
|
attr_reader :name, :type, :length, :decimal
|
7
9
|
|
10
|
+
# Initialize a new DBF::Column
|
11
|
+
#
|
12
|
+
# @param [String] name
|
13
|
+
# @param [String] type
|
14
|
+
# @param [Fixnum] length
|
15
|
+
# @param [Fixnum] decimal
|
8
16
|
def initialize(name, type, length, decimal)
|
9
17
|
@name, @type, @length, @decimal = strip_non_ascii_chars(name), type, length, decimal
|
10
18
|
|
@@ -12,6 +20,10 @@ module DBF
|
|
12
20
|
raise ColumnNameError, "column name cannot be empty" if @name.length == 0
|
13
21
|
end
|
14
22
|
|
23
|
+
# Cast value to native type
|
24
|
+
#
|
25
|
+
# @param [String] value
|
26
|
+
# @return [Fixnum, Float, Date, DateTime, Boolean, String]
|
15
27
|
def type_cast(value)
|
16
28
|
case type
|
17
29
|
when 'N' # number
|
@@ -29,28 +41,50 @@ module DBF
|
|
29
41
|
end
|
30
42
|
end
|
31
43
|
|
44
|
+
# Decode a DateTime value
|
45
|
+
#
|
46
|
+
# @param [String] value
|
47
|
+
# @return [DateTime]
|
32
48
|
def decode_datetime(value)
|
33
49
|
days, milliseconds = value.unpack('l2')
|
34
50
|
seconds = milliseconds / 1000
|
35
51
|
DateTime.jd(days, seconds/3600, seconds/60 % 60, seconds % 60)
|
36
52
|
end
|
37
53
|
|
54
|
+
# Decode a number value
|
55
|
+
#
|
56
|
+
# @param [String] value
|
57
|
+
# @return [Fixnum, Float]
|
38
58
|
def unpack_number(value)
|
39
59
|
decimal.zero? ? unpack_integer(value) : value.to_f
|
40
60
|
end
|
41
61
|
|
62
|
+
# Decode an integer
|
63
|
+
#
|
64
|
+
# @param [String] value
|
65
|
+
# @return [Fixnum]
|
42
66
|
def unpack_integer(value)
|
43
67
|
value.to_i
|
44
68
|
end
|
45
69
|
|
70
|
+
# Decode a boolean value
|
71
|
+
#
|
72
|
+
# @param [String] value
|
73
|
+
# @return [Boolean]
|
46
74
|
def boolean(value)
|
47
75
|
value.strip =~ /^(y|t)$/i ? true : false
|
48
76
|
end
|
49
77
|
|
78
|
+
# Schema definition
|
79
|
+
#
|
80
|
+
# @return [String]
|
50
81
|
def schema_definition
|
51
82
|
"\"#{name.underscore}\", #{schema_data_type}\n"
|
52
83
|
end
|
53
84
|
|
85
|
+
# Column type for schema definition
|
86
|
+
#
|
87
|
+
# @return [String]
|
54
88
|
def schema_data_type
|
55
89
|
case type
|
56
90
|
when "N"
|
@@ -70,7 +104,10 @@ module DBF
|
|
70
104
|
end
|
71
105
|
end
|
72
106
|
|
73
|
-
#
|
107
|
+
# Strip all non-ascii and non-printable characters
|
108
|
+
#
|
109
|
+
# @param [String] s
|
110
|
+
# @return [String]
|
74
111
|
def strip_non_ascii_chars(s)
|
75
112
|
# truncate the string at the first null character
|
76
113
|
s = s[0, s.index("\x00")] if s.index("\x00")
|
data/lib/dbf/record.rb
CHANGED
@@ -1,24 +1,40 @@
|
|
1
1
|
module DBF
|
2
|
+
|
3
|
+
# An instance of DBF::Record represents a row in the DBF file
|
2
4
|
class Record
|
3
5
|
attr_reader :attributes
|
6
|
+
attr_reader :memo_block_size
|
7
|
+
|
4
8
|
delegate :columns, :to => :@table
|
5
9
|
|
10
|
+
# Initialize a new DBF::Record
|
11
|
+
#
|
12
|
+
# @param [DBF::Table] table
|
6
13
|
def initialize(table)
|
7
14
|
@table, @data, @memo = table, table.data, table.memo
|
15
|
+
@memo_block_size = @table.memo_block_size
|
8
16
|
initialize_values
|
9
17
|
define_accessors
|
10
18
|
end
|
11
19
|
|
20
|
+
# Equality
|
21
|
+
#
|
22
|
+
# @param [DBF::Record] other
|
23
|
+
# @return [Boolean]
|
12
24
|
def ==(other)
|
13
25
|
other.respond_to?(:attributes) && other.attributes == attributes
|
14
26
|
end
|
15
27
|
|
28
|
+
# Maps a row to an array of values
|
29
|
+
#
|
30
|
+
# @return [Array]
|
16
31
|
def to_a
|
17
32
|
columns.map { |column| @attributes[column.name.underscore] }
|
18
33
|
end
|
19
34
|
|
20
35
|
private
|
21
36
|
|
37
|
+
# Defined attribute accessor methods
|
22
38
|
def define_accessors
|
23
39
|
columns.each do |column|
|
24
40
|
underscored_column_name = column.name.underscore
|
@@ -30,6 +46,7 @@ module DBF
|
|
30
46
|
end
|
31
47
|
end
|
32
48
|
|
49
|
+
# Initialize values for a row
|
33
50
|
def initialize_values
|
34
51
|
@attributes = columns.inject({}) do |hash, column|
|
35
52
|
if column.type == 'M'
|
@@ -44,17 +61,27 @@ module DBF
|
|
44
61
|
hash
|
45
62
|
end
|
46
63
|
end
|
47
|
-
|
64
|
+
|
65
|
+
# Unpack raw data from database
|
66
|
+
#
|
67
|
+
# @param [Fixnum] length
|
48
68
|
def unpack_data(length)
|
49
69
|
@data.read(length).unpack("a#{length}").first
|
50
70
|
end
|
51
|
-
|
71
|
+
|
72
|
+
# Reads a memo from the memo file
|
73
|
+
#
|
74
|
+
# @param [Fixnum] start_block
|
52
75
|
def read_memo(start_block)
|
53
76
|
return nil if !@table.has_memo_file? || start_block < 1
|
54
77
|
|
55
78
|
@table.memo_file_format == :fpt ? build_fpt_memo(start_block) : build_dbt_memo(start_block)
|
56
79
|
end
|
57
80
|
|
81
|
+
# Reconstructs a memo from an FPT memo file
|
82
|
+
#
|
83
|
+
# @param [Fixnum] start_block
|
84
|
+
# @return [String]
|
58
85
|
def build_fpt_memo(start_block)
|
59
86
|
@memo.seek(start_block * memo_block_size)
|
60
87
|
|
@@ -69,6 +96,10 @@ module DBF
|
|
69
96
|
memo_string
|
70
97
|
end
|
71
98
|
|
99
|
+
# Reconstucts a memo from an DBT memo file
|
100
|
+
#
|
101
|
+
# @param [Fixnum] start_block
|
102
|
+
# @return [String]
|
72
103
|
def build_dbt_memo(start_block)
|
73
104
|
@memo.seek(start_block * memo_block_size)
|
74
105
|
|
@@ -87,14 +118,16 @@ module DBF
|
|
87
118
|
memo_string
|
88
119
|
end
|
89
120
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
121
|
+
# The size in bytes of the content for each memo block
|
122
|
+
#
|
123
|
+
# @return [Fixnum]
|
94
124
|
def memo_block_content_size
|
95
125
|
memo_block_size - BLOCK_HEADER_SIZE
|
96
126
|
end
|
97
127
|
|
128
|
+
# The size in bytes of the entire memo
|
129
|
+
#
|
130
|
+
# @return [Fixnum]
|
98
131
|
def memo_content_size(memo_size)
|
99
132
|
(memo_size - memo_block_size) + BLOCK_HEADER_SIZE
|
100
133
|
end
|
data/lib/dbf/table.rb
CHANGED
@@ -1,120 +1,93 @@
|
|
1
1
|
module DBF
|
2
2
|
|
3
|
+
# DBF::Table is the primary interface to a single DBF file and provides
|
4
|
+
# methods for enumerating and searching the records.
|
3
5
|
class Table
|
4
|
-
include Enumerable
|
5
|
-
|
6
6
|
attr_reader :column_count # The total number of columns
|
7
7
|
attr_reader :columns # An array of DBF::Column
|
8
8
|
attr_reader :version # Internal dBase version number
|
9
9
|
attr_reader :last_updated # Last updated datetime
|
10
10
|
attr_reader :memo_file_format # :fpt or :dpt
|
11
11
|
attr_reader :memo_block_size # The block size for memo records
|
12
|
-
attr_reader :options # The options hash
|
12
|
+
attr_reader :options # The options hash used to initialize the table
|
13
13
|
attr_reader :data # DBF file handle
|
14
14
|
attr_reader :memo # Memo file handle
|
15
|
+
attr_reader :record_count # Total number of records
|
15
16
|
|
16
|
-
#
|
17
|
+
# Opens a DBF::Table
|
17
18
|
# Example:
|
18
19
|
# table = DBF::Table.new 'data.dbf'
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
@
|
20
|
+
#
|
21
|
+
# @param [String] path Path to the dbf file
|
22
|
+
def initialize(path)
|
23
|
+
@data = File.open(path, 'rb')
|
24
|
+
@memo = open_memo(path)
|
23
25
|
reload!
|
24
26
|
end
|
25
27
|
|
28
|
+
# Closes the table and memo file
|
29
|
+
def close
|
30
|
+
@data.close
|
31
|
+
@memo.close if @memo
|
32
|
+
end
|
33
|
+
|
26
34
|
# Reloads the database and memo files
|
27
35
|
def reload!
|
28
36
|
@records = nil
|
29
37
|
get_header_info
|
30
38
|
get_memo_header_info if @memo
|
31
39
|
get_column_descriptors
|
32
|
-
build_db_index
|
33
40
|
end
|
34
41
|
|
35
|
-
#
|
42
|
+
# Checks if there is a memo file
|
43
|
+
#
|
44
|
+
# @return [Boolean]
|
36
45
|
def has_memo_file?
|
37
46
|
@memo ? true : false
|
38
47
|
end
|
39
48
|
|
40
|
-
#
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
# Returns an instance of DBF::Column for <b>column_name</b>. The <b>column_name</b>
|
46
|
-
# can be a specified as either a symbol or string.
|
49
|
+
# Retrieve a Column by name
|
50
|
+
#
|
51
|
+
# @param [String, Symbol] column_name
|
52
|
+
# @return [DBF::Column]
|
47
53
|
def column(column_name)
|
48
54
|
@columns.detect {|f| f.name == column_name.to_s}
|
49
55
|
end
|
50
56
|
|
51
|
-
#
|
52
|
-
#
|
53
|
-
|
54
|
-
|
55
|
-
end
|
56
|
-
|
57
|
-
alias_method :rows, :records
|
58
|
-
|
57
|
+
# Calls block once for each record in the table. The record may be nil
|
58
|
+
# if the record has been marked as deleted.
|
59
|
+
#
|
60
|
+
# @yield [nil, DBF::Record]
|
59
61
|
def each
|
60
62
|
0.upto(@record_count - 1) do |n|
|
61
63
|
seek_to_record(n)
|
62
|
-
|
63
|
-
yield DBF::Record.new(self)
|
64
|
-
end
|
64
|
+
yield deleted_record? ? nil : DBF::Record.new(self)
|
65
65
|
end
|
66
66
|
end
|
67
67
|
|
68
|
-
#
|
69
|
-
def record(index)
|
70
|
-
records[index]
|
71
|
-
end
|
72
|
-
|
73
|
-
# Find records using a simple ActiveRecord-like syntax.
|
74
|
-
#
|
75
|
-
# Examples:
|
76
|
-
# table = DBF::Table.new 'mydata.dbf'
|
77
|
-
#
|
78
|
-
# # Find record number 5
|
79
|
-
# table.find(5)
|
80
|
-
#
|
81
|
-
# # Find all records for Keith Morrison
|
82
|
-
# table.find :all, :first_name => "Keith", :last_name => "Morrison"
|
83
|
-
#
|
84
|
-
# # Find first record
|
85
|
-
# table.find :first, :first_name => "Keith"
|
68
|
+
# Retrieve a record by index number
|
86
69
|
#
|
87
|
-
#
|
88
|
-
#
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
# AND key2 = 'value2'".
|
93
|
-
def find(command, options = {})
|
94
|
-
results = options.empty? ? records : records.select {|record| all_values_match?(record, options)}
|
95
|
-
|
96
|
-
case command
|
97
|
-
when Fixnum
|
98
|
-
record(command)
|
99
|
-
when :all
|
100
|
-
results
|
101
|
-
when :first
|
102
|
-
results.first
|
103
|
-
end
|
70
|
+
# @param [Fixnum] index
|
71
|
+
# @return [DBF::Record]
|
72
|
+
def record(index)
|
73
|
+
seek_to_record(index)
|
74
|
+
deleted_record? ? nil : DBF::Record.new(self)
|
104
75
|
end
|
105
76
|
|
106
77
|
alias_method :row, :record
|
107
78
|
|
108
|
-
#
|
79
|
+
# Human readable version description
|
80
|
+
#
|
81
|
+
# @return [String]
|
109
82
|
def version_description
|
110
83
|
VERSION_DESCRIPTIONS[version]
|
111
84
|
end
|
112
85
|
|
113
|
-
#
|
86
|
+
# Generate an ActiveRecord::Schema
|
114
87
|
#
|
115
88
|
# xBase data types are converted to generic types as follows:
|
116
|
-
# - Number columns are converted to :integer
|
117
|
-
#
|
89
|
+
# - Number columns with no decimals are converted to :integer
|
90
|
+
# - Number columns with decimals are converted to :float
|
118
91
|
# - Date columns are converted to :datetime
|
119
92
|
# - Logical columns are converted to :boolean
|
120
93
|
# - Memo columns are converted to :text
|
@@ -129,6 +102,9 @@ module DBF
|
|
129
102
|
# t.column :age, :integer
|
130
103
|
# t.column :notes, :text
|
131
104
|
# end
|
105
|
+
#
|
106
|
+
# @param [optional String] path
|
107
|
+
# @return [String]
|
132
108
|
def schema(path = nil)
|
133
109
|
s = "ActiveRecord::Schema.define do\n"
|
134
110
|
s << " create_table \"#{File.basename(@data.path, ".*")}\" do |t|\n"
|
@@ -139,105 +115,181 @@ module DBF
|
|
139
115
|
|
140
116
|
if path
|
141
117
|
File.open(path, 'w') {|f| f.puts(s)}
|
142
|
-
else
|
143
|
-
s
|
144
118
|
end
|
119
|
+
|
120
|
+
s
|
145
121
|
end
|
146
122
|
|
147
|
-
#
|
148
|
-
#
|
149
|
-
#
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
# Dumps all records into a CSV file
|
156
|
-
def to_csv(filename = nil)
|
157
|
-
filename = File.basename(@data.path, '.dbf') + '.csv' if filename.nil?
|
158
|
-
FCSV.open(filename, 'w', :force_quotes => true) do |csv|
|
159
|
-
records.each do |record|
|
123
|
+
# Dumps all records to a CSV file. If no filename is given then CSV is
|
124
|
+
# output to STDOUT.
|
125
|
+
#
|
126
|
+
# @param [optional String] path Defaults to basename of dbf file
|
127
|
+
def to_csv(path = nil)
|
128
|
+
path = File.basename(@data.path, '.dbf') + '.csv' if path.nil?
|
129
|
+
FCSV.open(path, 'w', :force_quotes => true) do |csv|
|
130
|
+
each do |record|
|
160
131
|
csv << record.to_a
|
161
132
|
end
|
162
133
|
end
|
163
134
|
end
|
164
135
|
|
136
|
+
# Find records using a simple ActiveRecord-like syntax.
|
137
|
+
#
|
138
|
+
# Examples:
|
139
|
+
# table = DBF::Table.new 'mydata.dbf'
|
140
|
+
#
|
141
|
+
# # Find record number 5
|
142
|
+
# table.find(5)
|
143
|
+
#
|
144
|
+
# # Find all records for Keith Morrison
|
145
|
+
# table.find :all, :first_name => "Keith", :last_name => "Morrison"
|
146
|
+
#
|
147
|
+
# # Find first record
|
148
|
+
# table.find :first, :first_name => "Keith"
|
149
|
+
#
|
150
|
+
# The <b>command</b> may be a record index, :all, or :first.
|
151
|
+
# <b>options</b> is optional and, if specified, should be a hash where the keys correspond
|
152
|
+
# to column names in the database. The values will be matched exactly with the value
|
153
|
+
# in the database. If you specify more than one key, all values must match in order
|
154
|
+
# for the record to be returned. The equivalent SQL would be "WHERE key1 = 'value1'
|
155
|
+
# AND key2 = 'value2'".
|
156
|
+
#
|
157
|
+
# @param [Fixnum, Symbol] command
|
158
|
+
# @param [optional, Hash] options Hash of search parameters
|
159
|
+
# @yield [optional, DBF::Record]
|
160
|
+
def find(command, options = {}, &block)
|
161
|
+
case command
|
162
|
+
when Fixnum
|
163
|
+
record(command)
|
164
|
+
when Array
|
165
|
+
command.map {|i| record(i)}
|
166
|
+
when :all
|
167
|
+
find_all(options, &block)
|
168
|
+
when :first
|
169
|
+
find_first(options)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
165
173
|
private
|
166
174
|
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
175
|
+
# Find all matching
|
176
|
+
#
|
177
|
+
# @param [Hash] options
|
178
|
+
# @yield [optional DBF::Record]
|
179
|
+
# @return [Array]
|
180
|
+
def find_all(options, &block)
|
181
|
+
results = []
|
182
|
+
each do |record|
|
183
|
+
if all_values_match?(record, options)
|
184
|
+
if block_given?
|
185
|
+
yield(record)
|
186
|
+
else
|
187
|
+
results << record
|
173
188
|
end
|
174
189
|
end
|
175
|
-
nil
|
176
|
-
end
|
177
|
-
|
178
|
-
def replace_extname(filename, extension)
|
179
|
-
filename.sub(/#{File.extname(filename)[1..-1]}$/, extension)
|
180
190
|
end
|
191
|
+
results
|
192
|
+
end
|
181
193
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
194
|
+
# Find first matching
|
195
|
+
#
|
196
|
+
# @param [Hash] options
|
197
|
+
# @return [DBF::Record, nil]
|
198
|
+
def find_first(options)
|
199
|
+
each do |record|
|
200
|
+
return record if all_values_match?(record, options)
|
189
201
|
end
|
202
|
+
nil
|
203
|
+
end
|
190
204
|
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
205
|
+
# Do all search parameters match?
|
206
|
+
#
|
207
|
+
# @param [DBF::Record] record
|
208
|
+
# @param [Hash] options
|
209
|
+
# @return [Boolean]
|
210
|
+
def all_values_match?(record, options)
|
211
|
+
options.all? {|key, value| record.attributes[key.to_s.underscore] == value}
|
212
|
+
end
|
196
213
|
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
214
|
+
# Open memo file
|
215
|
+
#
|
216
|
+
# @params [String] path
|
217
|
+
# @return [File]
|
218
|
+
def open_memo(path)
|
219
|
+
%w(fpt FPT dbt DBT).each do |extname|
|
220
|
+
filename = replace_extname(path, extname)
|
221
|
+
if File.exists?(filename)
|
222
|
+
@memo_file_format = extname.downcase.to_sym
|
223
|
+
return File.open(filename, 'rb')
|
204
224
|
end
|
205
|
-
# Reset the column count in case any were skipped
|
206
|
-
@column_count = @columns.size
|
207
|
-
|
208
|
-
@columns
|
209
225
|
end
|
226
|
+
nil
|
227
|
+
end
|
210
228
|
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
end
|
220
|
-
end
|
229
|
+
# Replace the file extension
|
230
|
+
#
|
231
|
+
# @param [String] path
|
232
|
+
# @param [String] extension
|
233
|
+
# @return [String]
|
234
|
+
def replace_extname(path, extension)
|
235
|
+
path.sub(/#{File.extname(path)[1..-1]}$/, extension)
|
236
|
+
end
|
221
237
|
|
222
|
-
|
223
|
-
|
224
|
-
|
238
|
+
# Is record marked for deletion
|
239
|
+
#
|
240
|
+
# @return [Boolean]
|
241
|
+
def deleted_record?
|
242
|
+
@data.read(1).unpack('a') == ['*']
|
243
|
+
end
|
225
244
|
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
245
|
+
# Determine database version, record count, header length and record length
|
246
|
+
def get_header_info
|
247
|
+
@data.rewind
|
248
|
+
@version, @record_count, @header_length, @record_length = @data.read(DBF_HEADER_SIZE).unpack('H2 x3 V v2')
|
249
|
+
@column_count = (@header_length - DBF_HEADER_SIZE + 1) / DBF_HEADER_SIZE
|
250
|
+
end
|
251
|
+
|
252
|
+
# Retrieves column information from the database
|
253
|
+
def get_column_descriptors
|
254
|
+
@columns = []
|
255
|
+
@column_count.times do
|
256
|
+
name, type, length, decimal = @data.read(32).unpack('a10 x a x4 C2')
|
257
|
+
if length > 0
|
258
|
+
@columns << Column.new(name.strip, type, length, decimal)
|
235
259
|
end
|
236
260
|
end
|
261
|
+
# Reset the column count in case any were skipped
|
262
|
+
@column_count = @columns.size
|
237
263
|
|
238
|
-
|
239
|
-
|
264
|
+
@columns
|
265
|
+
end
|
266
|
+
|
267
|
+
# Determines the memo block size and next available block
|
268
|
+
def get_memo_header_info
|
269
|
+
@memo.rewind
|
270
|
+
if @memo_file_format == :fpt
|
271
|
+
@memo_next_available_block, @memo_block_size = @memo.read(FPT_HEADER_SIZE).unpack('N x2 n')
|
272
|
+
@memo_block_size = 0 if @memo_block_size.nil?
|
273
|
+
else
|
274
|
+
@memo_block_size = 512
|
275
|
+
@memo_next_available_block = File.size(@memo.path) / @memo_block_size
|
240
276
|
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# Seek to a byte offset
|
280
|
+
#
|
281
|
+
# @params [Fixnum] offset
|
282
|
+
def seek(offset)
|
283
|
+
@data.seek(@header_length + offset)
|
284
|
+
end
|
285
|
+
|
286
|
+
# Seek to a record
|
287
|
+
#
|
288
|
+
# @param [Fixnum] index
|
289
|
+
def seek_to_record(index)
|
290
|
+
seek(index * @record_length)
|
291
|
+
end
|
292
|
+
|
241
293
|
end
|
242
294
|
|
243
295
|
end
|
@@ -7,7 +7,9 @@ describe DBF, :shared => true do
|
|
7
7
|
end
|
8
8
|
|
9
9
|
specify "records should be instances of DBF::Record" do
|
10
|
-
@table.
|
10
|
+
@table.each do |record|
|
11
|
+
record.should be_an_instance_of(DBF::Record)
|
12
|
+
end
|
11
13
|
end
|
12
14
|
|
13
15
|
specify "columns should be instances of DBF::Column" do
|
data/spec/spec_helper.rb
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
$:.unshift(File.dirname(__FILE__) +
|
2
|
-
require
|
3
|
-
require
|
4
|
-
require
|
1
|
+
$:.unshift(File.dirname(__FILE__) + '/../lib/')
|
2
|
+
require 'rubygems'
|
3
|
+
require 'spec'
|
4
|
+
require 'dbf'
|
5
|
+
require 'fileutils'
|
5
6
|
|
6
7
|
DB_PATH = File.dirname(__FILE__) + '/fixtures' unless defined?(DB_PATH)
|
7
8
|
|
@@ -9,4 +10,4 @@ Spec::Runner.configure do |config|
|
|
9
10
|
|
10
11
|
end
|
11
12
|
|
12
|
-
self.class.send :remove_const,
|
13
|
+
self.class.send :remove_const, 'Test' if defined? Test
|
data/spec/unit/record_spec.rb
CHANGED
@@ -25,26 +25,26 @@ describe DBF::Record do
|
|
25
25
|
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
26
26
|
table.column("ID").type.should == "N"
|
27
27
|
table.column("ID").decimal.should == 0
|
28
|
-
table.
|
28
|
+
table.record(1).attributes['id'].should be_kind_of(Integer)
|
29
29
|
end
|
30
30
|
|
31
31
|
it "should typecast number columns with decimals > 0 to Float" do
|
32
32
|
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
33
33
|
table.column("ID").type.should == "N"
|
34
34
|
table.column("COST").decimal.should == 2
|
35
|
-
table.
|
35
|
+
table.record(1).attributes['cost'].should be_kind_of(Float)
|
36
36
|
end
|
37
37
|
|
38
38
|
it "should typecast memo columns to String" do
|
39
39
|
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
40
40
|
table.column("DESC").type.should == "M"
|
41
|
-
table.
|
41
|
+
table.record(1).attributes['desc'].should be_kind_of(String)
|
42
42
|
end
|
43
43
|
|
44
44
|
it "should typecast logical columns to True or False" do
|
45
45
|
table = DBF::Table.new "#{DB_PATH}/dbase_30.dbf"
|
46
46
|
table.column("WEBINCLUDE").type.should == "L"
|
47
|
-
table.
|
47
|
+
table.record(1).attributes["webinclude"].should satisfy {|v| v == true || v == false}
|
48
48
|
end
|
49
49
|
end
|
50
50
|
|
@@ -85,13 +85,13 @@ describe DBF::Record do
|
|
85
85
|
end
|
86
86
|
end
|
87
87
|
|
88
|
-
describe '#to_a' do
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
end
|
88
|
+
# describe '#to_a' do
|
89
|
+
# it 'should return an ordered array of attribute values' do
|
90
|
+
# table = DBF::Table.new "#{DB_PATH}/dbase_8b.dbf"
|
91
|
+
# record = table.records[9]
|
92
|
+
# record.to_a.should == ["Ten records stored in this database", 10.0, nil, false, "0.100000000000000000", nil]
|
93
|
+
# end
|
94
|
+
# end
|
95
95
|
|
96
96
|
describe '#==' do
|
97
97
|
before do
|
data/spec/unit/table_spec.rb
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
require File.dirname(__FILE__) + "/../spec_helper"
|
2
|
-
require 'fileutils'
|
3
2
|
|
4
3
|
describe DBF::Table do
|
5
|
-
|
6
4
|
context "when initialized" do
|
7
5
|
before do
|
8
6
|
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
@@ -12,7 +10,7 @@ describe DBF::Table do
|
|
12
10
|
@table.data.should be_kind_of(File)
|
13
11
|
end
|
14
12
|
|
15
|
-
it "should
|
13
|
+
it "should load the memo file" do
|
16
14
|
@table.has_memo_file?.should be_true
|
17
15
|
@table.instance_eval("@memo").should be_kind_of(File)
|
18
16
|
end
|
@@ -36,74 +34,19 @@ describe DBF::Table do
|
|
36
34
|
it "should determine the database version" do
|
37
35
|
@table.version.should == "83"
|
38
36
|
end
|
39
|
-
|
40
37
|
end
|
41
38
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
46
|
-
table.find(5).should == table.record(5)
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
describe "with :all" do
|
51
|
-
before do
|
52
|
-
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
53
|
-
end
|
54
|
-
|
55
|
-
it "should return all records if options are empty" do
|
56
|
-
@table.find(:all).should == @table.records
|
57
|
-
end
|
58
|
-
|
59
|
-
it "should return matching records when used with options" do
|
60
|
-
@table.find(:all, "WEIGHT" => 0.0).should == @table.select {|r| r.attributes["weight"] == 0.0}
|
61
|
-
end
|
62
|
-
|
63
|
-
it "with multiple options should search for all search terms as if using AND" do
|
64
|
-
@table.find(:all, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should == []
|
65
|
-
end
|
66
|
-
|
67
|
-
it "should match original column names" do
|
68
|
-
@table.find(:all, "WEIGHT" => 0.0).should_not be_empty
|
69
|
-
end
|
70
|
-
|
71
|
-
it "should match symbolized column names" do
|
72
|
-
@table.find(:all, :WEIGHT => 0.0).should_not be_empty
|
73
|
-
end
|
74
|
-
|
75
|
-
it "should match downcased column names" do
|
76
|
-
@table.find(:all, "weight" => 0.0).should_not be_empty
|
77
|
-
end
|
78
|
-
|
79
|
-
it "should match symbolized downcased column names" do
|
80
|
-
@table.find(:all, :weight => 0.0).should_not be_empty
|
81
|
-
end
|
39
|
+
context "when closed" do
|
40
|
+
before do
|
41
|
+
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
82
42
|
end
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
end
|
88
|
-
|
89
|
-
it "should return the first record if options are empty" do
|
90
|
-
@table.find(:first).should == @table.records.first
|
91
|
-
end
|
92
|
-
|
93
|
-
it "should return the first matching record when used with options" do
|
94
|
-
@table.find(:first, "CODE" => "C").should == @table.record(5)
|
95
|
-
end
|
96
|
-
|
97
|
-
it "with multiple options should search for all search terms as if using AND" do
|
98
|
-
@table.find(:first, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should be_nil
|
99
|
-
end
|
43
|
+
|
44
|
+
it "should close the data file" do
|
45
|
+
@table.close
|
46
|
+
lambda { @table.record(1) }.should raise_error(IOError)
|
100
47
|
end
|
101
48
|
end
|
102
49
|
|
103
|
-
describe "#reload" do
|
104
|
-
# TODO
|
105
|
-
end
|
106
|
-
|
107
50
|
describe "#column" do
|
108
51
|
it "should accept a string or symbol as input" do
|
109
52
|
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
@@ -139,22 +82,9 @@ describe DBF::Table do
|
|
139
82
|
end
|
140
83
|
|
141
84
|
describe '#replace_extname' do
|
142
|
-
it
|
85
|
+
it "should change the file extension" do
|
143
86
|
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
144
|
-
table.send(:replace_extname,
|
145
|
-
end
|
146
|
-
end
|
147
|
-
|
148
|
-
describe '#each' do
|
149
|
-
it 'should enumerate all records' do
|
150
|
-
table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
151
|
-
records = []
|
152
|
-
table.each do |record|
|
153
|
-
records << record
|
154
|
-
end
|
155
|
-
|
156
|
-
records.map! { |r| r.attributes }
|
157
|
-
records.should == table.records.map {|r| r.attributes}
|
87
|
+
table.send(:replace_extname, 'dbase_83.dbf', 'fpt').should == 'dbase_83.fpt'
|
158
88
|
end
|
159
89
|
end
|
160
90
|
|
@@ -178,6 +108,108 @@ describe DBF::Table do
|
|
178
108
|
File.exists?('test.csv').should be_true
|
179
109
|
end
|
180
110
|
end
|
111
|
+
|
112
|
+
describe "#record" do
|
113
|
+
before do
|
114
|
+
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
115
|
+
|
116
|
+
@records = []
|
117
|
+
@table.each {|record| @records << record}
|
118
|
+
end
|
119
|
+
|
120
|
+
it "should nullify deleted records" do
|
121
|
+
@table.stub!(:deleted_record?).and_return(true)
|
122
|
+
@table.record(5).should be_nil
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
describe "#find" do
|
127
|
+
describe "with index" do
|
128
|
+
before do
|
129
|
+
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
130
|
+
end
|
131
|
+
|
132
|
+
it "should return the correct record" do
|
133
|
+
@table.find(5).should == @table.record(5)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
describe 'with array of indexes' do
|
138
|
+
before do
|
139
|
+
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
140
|
+
end
|
141
|
+
|
142
|
+
it "should return the correct records" do
|
143
|
+
@table.find([1, 5, 10]).should == [@table.record(1), @table.record(5), @table.record(10)]
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
describe "with :all" do
|
148
|
+
before do
|
149
|
+
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
150
|
+
|
151
|
+
@records = []
|
152
|
+
@table.each {|record| @records << record}
|
153
|
+
end
|
154
|
+
|
155
|
+
it "should accept a block" do
|
156
|
+
records = []
|
157
|
+
@table.find(:all, :weight => 0.0) do |record|
|
158
|
+
records << record
|
159
|
+
end
|
160
|
+
records.should == @table.find(:all, :weight => 0.0)
|
161
|
+
end
|
162
|
+
|
163
|
+
it "should return all records if options are empty" do
|
164
|
+
@table.find(:all).should == @records
|
165
|
+
end
|
166
|
+
|
167
|
+
it "should return matching records when used with options" do
|
168
|
+
@table.find(:all, "WEIGHT" => 0.0).should == @records.select {|r| r.attributes["weight"] == 0.0}
|
169
|
+
end
|
170
|
+
|
171
|
+
it "should AND multiple search terms" do
|
172
|
+
@table.find(:all, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should == []
|
173
|
+
end
|
174
|
+
|
175
|
+
it "should match original column names" do
|
176
|
+
@table.find(:all, "WEIGHT" => 0.0).should_not be_empty
|
177
|
+
end
|
178
|
+
|
179
|
+
it "should match symbolized column names" do
|
180
|
+
@table.find(:all, :WEIGHT => 0.0).should_not be_empty
|
181
|
+
end
|
182
|
+
|
183
|
+
it "should match downcased column names" do
|
184
|
+
@table.find(:all, "weight" => 0.0).should_not be_empty
|
185
|
+
end
|
186
|
+
|
187
|
+
it "should match symbolized downcased column names" do
|
188
|
+
@table.find(:all, :weight => 0.0).should_not be_empty
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
describe "with :first" do
|
193
|
+
before do
|
194
|
+
@table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
|
195
|
+
|
196
|
+
@records = []
|
197
|
+
@table.each {|record| @records << record}
|
198
|
+
end
|
199
|
+
|
200
|
+
it "should return the first record if options are empty" do
|
201
|
+
@table.find(:first).should == @records.first
|
202
|
+
end
|
203
|
+
|
204
|
+
it "should return the first matching record when used with options" do
|
205
|
+
@table.find(:first, "CODE" => "C").should == @records[5]
|
206
|
+
end
|
207
|
+
|
208
|
+
it "should AND multiple search terms" do
|
209
|
+
@table.find(:first, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should be_nil
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
181
213
|
|
182
214
|
end
|
183
215
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dbf
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Keith Morrison
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date: 2009-12-
|
12
|
+
date: 2009-12-14 00:00:00 +01:00
|
13
13
|
default_executable: dbf
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|