ruby-adt 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,123 @@
1
+ # NOTICE
2
+ This is an early version and I don't expect it to work in all cases.
3
+
4
+ Currently, the biggest item that I am working on is getting dates extracted correctly.
5
+
6
+ If you have suggestions or an .adt file that has issues being read then let me know and I might be able to work on it.
7
+
8
+ # Ruby-ADT
9
+
10
+ Ruby ADT is a small fast library for reading Advantage Database Server database files (.ADT)
11
+
12
+ * Project page: <http://github.com/chasemgray/Ruby-ADT>
13
+ * Report bugs: <http://github.com/chasemgray/Ruby-ADT/issues>
14
+ * Questions: Email <mailto:chase@ratchetsoftware.com> and put ADT somewhere in the subject line
15
+
16
+ ## Installation
17
+
18
+ gem install ruby-adt
19
+
20
+ ## Basic Usage
21
+
22
+ Load an ADT file:
23
+
24
+ require 'rubygems'
25
+ require 'ruby-adt'
26
+
27
+ table = ADT::Table.new("test.adt")
28
+
29
+ Enumerate all records
30
+
31
+ table.each do |record|
32
+ puts record.name
33
+ puts record.email
34
+ end
35
+
36
+ Load a single record using <tt>record</tt> or <tt>find</tt>
37
+
38
+ table.record(6)
39
+ table.find(6)
40
+
41
+ Attributes can also be accessed through the attributes hash in original or
42
+ underscored form or as an accessor method using the underscored name.
43
+
44
+ table.record(4).attributes["PhoneBook"]
45
+ table.record(4).attributes["phone_book"]
46
+ table.record(4).phone_book
47
+
48
+ Search for records using a simple hash format. Multiple search criteria are
49
+ ANDed. Use the block form of find if the resulting recordset could be large
50
+ otherwise all records will be loaded into memory.
51
+
52
+ # find all records with first_name equal to Keith
53
+ table.find(:all, :first_name => 'Keith') do |record|
54
+ puts record.last_name
55
+ end
56
+
57
+ # find all records with first_name equal to Keith and last_name equal
58
+ # to Morrison
59
+ table.find(:all, :first_name => 'Keith', :last_name => 'Morrison') do |record|
60
+ puts record.last_name
61
+ end
62
+
63
+ # find the first record with first_name equal to Keith
64
+ table.find :first, :first_name => 'Keith'
65
+
66
+ # find record number 10
67
+ table.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 'adt'
74
+
75
+ class CreateBooks < ActiveRecord::Migration
76
+ def self.up
77
+ table = ADT::Table.new('db/adt/books.adt')
78
+ eval(table.schema)
79
+
80
+ table.each do |record|
81
+ Book.create(record.attributes)
82
+ end
83
+ end
84
+
85
+ def self.down
86
+ drop_table :books
87
+ end
88
+ end
89
+
90
+ ## Limitations and known bugs
91
+
92
+ * ADT is read-only
93
+ * External index files are not used
94
+
95
+ ## Acknowledgements
96
+
97
+
98
+ ## License
99
+
100
+ (The MIT Licence)
101
+
102
+ Copyright (c) 2010-2010 Chase Gray <mailto:chase@ratchetsoftware.com>
103
+
104
+ Permission is hereby granted, free of charge, to any person
105
+ obtaining a copy of this software and associated documentation
106
+ files (the "Software"), to deal in the Software without
107
+ restriction, including without limitation the rights to use,
108
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
109
+ copies of the Software, and to permit persons to whom the
110
+ Software is furnished to do so, subject to the following
111
+ conditions:
112
+
113
+ The above copyright notice and this permission notice shall be
114
+ included in all copies or substantial portions of the Software.
115
+
116
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
117
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
118
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
119
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
120
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
121
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
122
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
123
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,32 @@
1
+ PROJECT_ROOT = File.expand_path(File.dirname(__FILE__))
2
+ $: << File.join(PROJECT_ROOT, 'lib')
3
+
4
+ require 'rubygems'
5
+ require 'jeweler'
6
+ require 'spec/rake/spectask'
7
+
8
+ Jeweler::Tasks.new do |s|
9
+ s.name = 'ruby-adt'
10
+ s.description = 'A small fast library for reading Advantage Database Server database files (ADT).'
11
+ s.summary = 'Read ADT files'
12
+ s.platform = Gem::Platform::RUBY
13
+ s.authors = ['Chase Gray']
14
+ s.email = 'chase@ratchetsoftware.com'
15
+ s.add_dependency('activesupport', ['>= 2.1.0'])
16
+ s.homepage = 'http://github.com/chasemgray/Ruby-ADT'
17
+ end
18
+
19
+ Jeweler::GemcutterTasks.new
20
+
21
+ task :default => :spec
22
+
23
+ desc "Run specs"
24
+ Spec::Rake::SpecTask.new :spec do |t|
25
+ t.spec_files = FileList['spec/**/*spec.rb']
26
+ end
27
+
28
+ desc "Run spec docs"
29
+ Spec::Rake::SpecTask.new :specdoc do |t|
30
+ t.spec_opts = ["-f specdoc"]
31
+ t.spec_files = FileList['spec/**/*spec.rb']
32
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.5.0
@@ -0,0 +1,36 @@
1
+ 04 followed by 32-bit zero (00 00 00 00) seem to precede each new row of data.
2
+
3
+ Seems like for each column we have to know how long each data set is because there is no separator between data.
4
+
5
+ The actual data is stored at the end of the file.
6
+ There is a header with unknown information followed by information about the columns.
7
+ There doesn't seem to be a footer
8
+
9
+ 400 byte header
10
+
11
+ byte 24 in the header specifies the number of rows. 32-bits I believe
12
+ byte 33 in the header specifies the number of columns - 16-bits - the previous bytes also change when columns are added and removed but I don't know why. It doesn't matter for my purposes.
13
+
14
+
15
+ column names follow header
16
+
17
+ Column names have a reserved 128 characters (bytes)
18
+ column names are followed by 72 bytes of information about the column.
19
+
20
+ 2 bytes after the 128 byte column name is the column type:
21
+ 00 04 - character
22
+ 00 0A - double
23
+ 00 0B - integer
24
+ 00 0C - shortint
25
+ 00 14 - cicharacter
26
+ 00 03 - date
27
+ 00 0D - time
28
+ 00 0E - timestamp
29
+ 00 0F - autoinc
30
+
31
+ 8th byte after 128 byte column name specifies the length in bytes
32
+
33
+
34
+ how to parse integers?
35
+ how to parse doubles?
36
+ how to parse dates and times?
@@ -0,0 +1,22 @@
1
+ require 'rubygems'
2
+ require 'date'
3
+ require 'active_support'
4
+
5
+ if RUBY_VERSION > '1.9'
6
+ require 'csv'
7
+ unless defined? FCSV
8
+ class Object
9
+ FCSV = CSV
10
+ alias_method :FCSV, :CSV
11
+ end
12
+ end
13
+ else
14
+ require 'fastercsv'
15
+ end
16
+
17
+ require 'adt/globals'
18
+ require 'adt/record'
19
+ require 'adt/column'
20
+ require 'adt/table'
21
+
22
+
@@ -0,0 +1,91 @@
1
+ module ADT
2
+ class ColumnLengthError < ADTError; end
3
+ class ColumnNameError < ADTError; end
4
+
5
+ TYPES = {4 => 'character', 10 => 'double', 11 => 'integer', 12 => 'short', 20 => 'cicharacter', 3 => 'date', 13 => 'time', 14 => 'timestamp', 15 => 'autoinc'}
6
+ FLAGS = {'character' => 'A', 'double' => 'D', 'integer' => 'i', 'short' => 'S', 'cicharacter' => 'A', 'date' => '?', 'time' => '?', 'timestamp' => '?', 'autoinc' => 'I'}
7
+
8
+
9
+ class Column
10
+ attr_reader :name, :type, :length
11
+
12
+ # Initialize a new ADT::Column
13
+ #
14
+ # @param [String] name
15
+ # @param [String] type
16
+ # @param [Fixnum] length
17
+ def initialize(name, type, length)
18
+ @name, @type, @length = strip_non_ascii_chars(name), type, length
19
+
20
+ raise ColumnLengthError, "field length must be greater than 0" unless length > 0
21
+ raise ColumnNameError, "column name cannot be empty" if @name.length == 0
22
+ end
23
+
24
+ def data_type(id)
25
+ TYPES[id]
26
+ end
27
+
28
+ def flag(type, length = 0)
29
+ data_type = data_type(type)
30
+ flag = FLAGS[data_type]
31
+ if flag.eql? 'A'
32
+ return flag + length.to_s
33
+ end
34
+ return flag
35
+ end
36
+
37
+ # Decode a DateTime value
38
+ #
39
+ # @param [String] value
40
+ # @return [DateTime]
41
+ def decode_datetime(value)
42
+ days, milliseconds = value.unpack('l2')
43
+ seconds = milliseconds / 1000
44
+ DateTime.jd(days, seconds/3600, seconds/60 % 60, seconds % 60) rescue nil
45
+ end
46
+
47
+ # Schema definition
48
+ #
49
+ # @return [String]
50
+ def schema_definition
51
+ "\"#{name.underscore}\", #{schema_data_type}\n"
52
+ end
53
+
54
+ # Column type for schema definition
55
+ #
56
+ # @return [String]
57
+ def schema_data_type
58
+ case data_type(type)
59
+ when "character"
60
+ ":string, :limit => #{length}"
61
+ when "cicharacter"
62
+ ":string, :limit => #{length}"
63
+ when "double"
64
+ ":float"
65
+ when "date"
66
+ ":date"
67
+ when "time"
68
+ ":timestamp"
69
+ when "timestamp"
70
+ ":timestamp"
71
+ when "integer"
72
+ ":integer"
73
+ when "autoinc"
74
+ ":integer"
75
+ else
76
+ ":string, :limit => #{length}"
77
+ end
78
+ end
79
+
80
+ # Strip all non-ascii and non-printable characters
81
+ #
82
+ # @param [String] s
83
+ # @return [String]
84
+ def strip_non_ascii_chars(s)
85
+ # truncate the string at the first null character
86
+ s = s[0, s.index("\x00")] if s.index("\x00")
87
+
88
+ s.gsub(/[^\x20-\x7E]/,"")
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,13 @@
1
+ module ADT
2
+ class ADTError < StandardError; end
3
+
4
+
5
+
6
+ MS_PER_SECOND = 1000
7
+ MS_PER_MINUTE = MS_PER_SECOND * 60
8
+ MS_PER_HOUR = MS_PER_MINUTE * 60
9
+
10
+ HEADER_LENGTH = 400
11
+
12
+ COLUMN_LENGTH = 200
13
+ end
@@ -0,0 +1,65 @@
1
+ module ADT
2
+ # An instance of ADT::Record represents a row in the ADT file
3
+ class Record
4
+ attr_reader :attributes
5
+
6
+ delegate :columns, :to => :@table
7
+
8
+ # Initialize a new ADT::Record
9
+ #
10
+ # @param [ADT::Table] table
11
+ def initialize(table)
12
+ @table, @data = table, table.data
13
+
14
+ initialize_values
15
+ define_accessors
16
+ end
17
+
18
+ # Equality
19
+ #
20
+ # @param [ADT::Record] other
21
+ # @return [Boolean]
22
+ def ==(other)
23
+ other.respond_to?(:attributes) && other.attributes == attributes
24
+ end
25
+
26
+ # Maps a row to an array of values
27
+ #
28
+ # @return [Array]
29
+ def to_a
30
+ columns.map { |column| @attributes[column.name.underscore] }
31
+ end
32
+
33
+ private
34
+
35
+ # Defined attribute accessor methods
36
+ def define_accessors
37
+ columns.each do |column|
38
+ underscored_column_name = column.name.underscore
39
+ unless respond_to?(underscored_column_name)
40
+ self.class.send :define_method, underscored_column_name do
41
+ @attributes[column.name.underscore]
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # Initialize values for a row
48
+ def initialize_values
49
+ #skip the first 5 bytes, don't know what they are for and they don't contain the data.
50
+ @data.read(5)
51
+
52
+ @attributes = columns.inject({}) do |hash, column|
53
+
54
+ #get the unpack flag to get this data.
55
+ value = @data.read(column.length).unpack("#{column.flag(column.type, column.length)}").first
56
+ hash[column.name] = value
57
+ hash[column.name.underscore] = value
58
+
59
+ hash
60
+ end
61
+ end
62
+
63
+
64
+ end
65
+ end
@@ -0,0 +1,259 @@
1
+ module ADT
2
+
3
+ # ADT::Table is the primary interface to a single ADT file and provides
4
+ # methods for enumerating and searching the records.
5
+ class Table
6
+ attr_reader :column_count # The total number of columns
7
+ attr_reader :columns # An array of DBF::Column
8
+ attr_reader :options # The options hash used to initialize the table
9
+ attr_reader :data # ADT file handle
10
+ attr_reader :record_count # Total number of records
11
+
12
+ # Opens a ADT:Table
13
+ # Example:
14
+ # table = ADT::Table.new 'data.adt'
15
+ #
16
+ # @param [String] path Path to the adt file
17
+ def initialize(path)
18
+ @data = File.open(path, 'rb')
19
+ reload!
20
+ end
21
+
22
+ # Closes the table
23
+ def close
24
+ @data.close
25
+ end
26
+
27
+ # Reloads the database
28
+ def reload!
29
+ @records = nil
30
+ get_header_info
31
+ get_column_descriptors
32
+ end
33
+
34
+
35
+ # Retrieve a Column by name
36
+ #
37
+ # @param [String, Symbol] column_name
38
+ # @return [ADT::Column]
39
+ def column(column_name)
40
+ @columns.detect {|f| f.name == column_name.to_s}
41
+ end
42
+
43
+ # Calls block once for each record in the table. The record may be nil
44
+ # if the record has been marked as deleted.
45
+ #
46
+ # @yield [nil, ADT::Record]
47
+ def each
48
+ 0.upto(@record_count - 1) do |n|
49
+ seek_to_record(n)
50
+ yield ADT::Record.new(self)
51
+ end
52
+ end
53
+
54
+ # Retrieve a record by index number
55
+ #
56
+ # @param [Fixnum] index
57
+ # @return [ADT::Record]
58
+ def record(index)
59
+ seek_to_record(index)
60
+ ADT::Record.new(self)
61
+ end
62
+
63
+ alias_method :row, :record
64
+
65
+
66
+ # Generate an ActiveRecord::Schema
67
+ #
68
+ # xBase data types are converted to generic types as follows:
69
+ # - Number columns with no decimals are converted to :integer
70
+ # - Number columns with decimals are converted to :float
71
+ # - Date columns are converted to :datetime
72
+ # - Logical columns are converted to :boolean
73
+ # - Memo columns are converted to :text
74
+ # - Character columns are converted to :string and the :limit option is set
75
+ # to the length of the character column
76
+ #
77
+ # Example:
78
+ # create_table "mydata" do |t|
79
+ # t.column :name, :string, :limit => 30
80
+ # t.column :last_update, :datetime
81
+ # t.column :is_active, :boolean
82
+ # t.column :age, :integer
83
+ # t.column :notes, :text
84
+ # end
85
+ #
86
+ # @param [optional String] path
87
+ # @return [String]
88
+ def schema(path = nil)
89
+ s = "ActiveRecord::Schema.define do\n"
90
+ s << " create_table \"#{File.basename(@data.path, ".*")}\" do |t|\n"
91
+ columns.each do |column|
92
+ s << " t.column #{column.schema_definition}"
93
+ end
94
+ s << " end\nend"
95
+
96
+ if path
97
+ File.open(path, 'w') {|f| f.puts(s)}
98
+ end
99
+
100
+ s
101
+ end
102
+
103
+ def to_a
104
+ records = []
105
+ each {|record| records << record if record}
106
+ records
107
+ end
108
+
109
+ # Dumps all records to a CSV file. If no filename is given then CSV is
110
+ # output to STDOUT.
111
+ #
112
+ # @param [optional String] path Defaults to basename of adt file
113
+ def to_csv(path = nil)
114
+ path = File.basename(@data.path, '.adt') + '.csv' if path.nil?
115
+ FCSV.open(path, 'w', :force_quotes => true) do |csv|
116
+ each do |record|
117
+ csv << record.to_a
118
+ end
119
+ end
120
+ end
121
+
122
+ # Find records using a simple ActiveRecord-like syntax.
123
+ #
124
+ # Examples:
125
+ # table = ADT::Table.new 'mydata.adt'
126
+ #
127
+ # # Find record number 5
128
+ # table.find(5)
129
+ #
130
+ # # Find all records for Chase Gray
131
+ # table.find :all, :first_name => "Chase", :last_name => "Gray"
132
+ #
133
+ # # Find first record
134
+ # table.find :first, :first_name => "Chase"
135
+ #
136
+ # The <b>command</b> may be a record index, :all, or :first.
137
+ # <b>options</b> is optional and, if specified, should be a hash where the keys correspond
138
+ # to column names in the database. The values will be matched exactly with the value
139
+ # in the database. If you specify more than one key, all values must match in order
140
+ # for the record to be returned. The equivalent SQL would be "WHERE key1 = 'value1'
141
+ # AND key2 = 'value2'".
142
+ #
143
+ # @param [Fixnum, Symbol] command
144
+ # @param [optional, Hash] options Hash of search parameters
145
+ # @yield [optional, ADT::Record]
146
+ def find(command, options = {}, &block)
147
+ case command
148
+ when Fixnum
149
+ record(command)
150
+ when Array
151
+ command.map {|i| record(i)}
152
+ when :all
153
+ find_all(options, &block)
154
+ when :first
155
+ find_first(options)
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ # Find all matching
162
+ #
163
+ # @param [Hash] options
164
+ # @yield [optional ADT::Record]
165
+ # @return [Array]
166
+ def find_all(options, &block)
167
+ results = []
168
+ each do |record|
169
+ if all_values_match?(record, options)
170
+ if block_given?
171
+ yield(record)
172
+ else
173
+ results << record
174
+ end
175
+ end
176
+ end
177
+ results
178
+ end
179
+
180
+ # Find first matching
181
+ #
182
+ # @param [Hash] options
183
+ # @return [ADT::Record, nil]
184
+ def find_first(options)
185
+ each do |record|
186
+ return record if all_values_match?(record, options)
187
+ end
188
+ nil
189
+ end
190
+
191
+ # Do all search parameters match?
192
+ #
193
+ # @param [ADT::Record] record
194
+ # @param [Hash] options
195
+ # @return [Boolean]
196
+ def all_values_match?(record, options)
197
+ options.all? {|key, value| record.attributes[key.to_s.underscore] == value}
198
+ end
199
+
200
+
201
+ # Replace the file extension
202
+ #
203
+ # @param [String] path
204
+ # @param [String] extension
205
+ # @return [String]
206
+ def replace_extname(path, extension)
207
+ path.sub(/#{File.extname(path)[1..-1]}$/, extension)
208
+ end
209
+
210
+
211
+ # Determine record count, record_count, and record length
212
+ def get_header_info
213
+ @data.rewind
214
+
215
+ #column_count_offset = 33, record_count_offset = 24, record_length_offset = 36
216
+ @record_count, @data_offset, @record_length = data.read(HEADER_LENGTH).unpack("@24 I x4 I I")
217
+ @column_count = (@data_offset-400)/200
218
+ end
219
+
220
+
221
+ # Retrieves column information from the database
222
+ def get_column_descriptors
223
+ #skip past header to get to column information
224
+ @data.seek(HEADER_LENGTH)
225
+
226
+ # column names are the first 128 bytes and column info takes up the last 72 bytes.
227
+ # byte 130 contains a 16-bit column type
228
+ # byte 136 contains a 16-bit length field
229
+ @columns = []
230
+ @column_count.times do
231
+ name, type, length = @data.read(200).unpack('A128 x S x4 S')
232
+ if length > 0
233
+ @columns << Column.new(name.strip, type, length)
234
+ end
235
+ end
236
+ # Reset the column count in case any were skipped
237
+ @column_count = @columns.size
238
+
239
+ @columns
240
+ end
241
+
242
+
243
+ # Seek to a byte offset in the record data
244
+ #
245
+ # @params [Fixnum] offset
246
+ def seek(offset)
247
+ @data.seek(@data_offset + offset)
248
+ end
249
+
250
+ # Seek to a record
251
+ #
252
+ # @param [Fixnum] index
253
+ def seek_to_record(index)
254
+ seek(index * @record_length)
255
+ end
256
+
257
+ end
258
+
259
+ end
Binary file
Binary file
@@ -0,0 +1,54 @@
1
+ describe DBF, :shared => true do
2
+ specify "sum of column lengths should equal record length specified in header" do
3
+ header_record_length = @table.instance_eval {@record_length}
4
+ sum_of_column_lengths = @table.columns.inject(1) {|sum, column| sum + column.length}
5
+
6
+ header_record_length.should == sum_of_column_lengths
7
+ end
8
+
9
+ specify "records should be instances of DBF::Record" do
10
+ @table.each do |record|
11
+ record.should be_an_instance_of(DBF::Record)
12
+ end
13
+ end
14
+
15
+ specify "columns should be instances of DBF::Column" do
16
+ @table.columns.all? {|column| column.should be_an_instance_of(DBF::Column)}
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 +)
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(column_name.underscore)
51
+ end
52
+ end
53
+
54
+ end
@@ -0,0 +1,13 @@
1
+ $:.unshift(File.dirname(__FILE__) + '/../lib/')
2
+ require 'rubygems'
3
+ require 'spec'
4
+ require 'adt'
5
+ require 'fileutils'
6
+
7
+ DB_PATH = File.dirname(__FILE__) + '/fixtures' unless defined?(DB_PATH)
8
+
9
+ Spec::Runner.configure do |config|
10
+
11
+ end
12
+
13
+ self.class.send :remove_const, 'Test' if defined? Test
@@ -0,0 +1,137 @@
1
+ require File.dirname(__FILE__) + "/../spec_helper"
2
+
3
+ describe DBF::Column do
4
+
5
+ context "when initialized" do
6
+ before do
7
+ @column = DBF::Column.new "ColumnName", "N", 1, 0
8
+ end
9
+
10
+ it "should set the #name accessor" do
11
+ @column.name.should == "ColumnName"
12
+ end
13
+
14
+ it "should set the #type accessor" do
15
+ @column.type.should == "N"
16
+ end
17
+
18
+ it "should set the #length accessor" do
19
+ @column.length.should == 1
20
+ end
21
+
22
+ it "should set the #decimal accessor" do
23
+ @column.decimal.should == 0
24
+ end
25
+
26
+ it "should raise an error if length is greater than 0" do
27
+ lambda { DBF::Column.new "ColumnName", "N", -1, 0 }.should raise_error(DBF::ColumnLengthError)
28
+ end
29
+
30
+ it "should raise error on emtpy column names" do
31
+ lambda { DBF::Column.new "\xFF\xFC", "N", 1, 0 }.should raise_error(DBF::ColumnNameError)
32
+ end
33
+
34
+ end
35
+
36
+ context "#type_cast" do
37
+ it "should cast numbers with decimals to Float" do
38
+ value = "13.5"
39
+ column = DBF::Column.new "ColumnName", "N", 2, 1
40
+ column.type_cast(value).should == 13.5
41
+ end
42
+
43
+ it "should cast numbers with no decimals to Integer" do
44
+ value = "135"
45
+ column = DBF::Column.new "ColumnName", "N", 3, 0
46
+ column.type_cast(value).should == 135
47
+ end
48
+
49
+ it "should cast :integer to Integer" do
50
+ value = "135"
51
+ column = DBF::Column.new "ColumnName", "I", 3, 0
52
+ column.type_cast(value).should == 135
53
+ end
54
+
55
+ it "should cast boolean to True" do
56
+ value = "y"
57
+ column = DBF::Column.new "ColumnName", "L", 1, 0
58
+ column.type_cast(value).should == true
59
+ end
60
+
61
+ it "should cast boolean to False" do
62
+ value = "n"
63
+ column = DBF::Column.new "ColumnName", "L", 1, 0
64
+ column.type_cast(value).should == false
65
+ end
66
+
67
+ it "should cast datetime columns to DateTime" do
68
+ value = "Nl%\000\300Z\252\003"
69
+ column = DBF::Column.new "ColumnName", "T", 16, 0
70
+ column.type_cast(value).should == "2002-10-10T17:04:56+00:00"
71
+ end
72
+
73
+ it "should cast invalid datetime columns to nil" do
74
+ value = "Nl%\000\000A\000\999"
75
+ column = DBF::Column.new "ColumnName", "T", 16, 0
76
+ column.type_cast(value).should be_nil
77
+ end
78
+
79
+ it "should cast date columns to Date" do
80
+ value = "20050712"
81
+ column = DBF::Column.new "ColumnName", "D", 8, 0
82
+ column.type_cast(value).should == Date.new(2005,7,12)
83
+ end
84
+ end
85
+
86
+ context "#schema_definition" do
87
+ it "should define an integer column if type is (N)umber with 9 decimals" do
88
+ column = DBF::Column.new "ColumnName", "N", 1, 0
89
+ column.schema_definition.should == "\"column_name\", :integer\n"
90
+ end
91
+
92
+ it "should define a float colmn if type is (N)umber with more than 0 decimals" do
93
+ column = DBF::Column.new "ColumnName", "N", 1, 2
94
+ column.schema_definition.should == "\"column_name\", :float\n"
95
+ end
96
+
97
+ it "should define a date column if type is (D)ate" do
98
+ column = DBF::Column.new "ColumnName", "D", 8, 0
99
+ column.schema_definition.should == "\"column_name\", :date\n"
100
+ end
101
+
102
+ it "should define a datetime column if type is (D)ate" do
103
+ column = DBF::Column.new "ColumnName", "T", 16, 0
104
+ column.schema_definition.should == "\"column_name\", :datetime\n"
105
+ end
106
+
107
+ it "should define a boolean column if type is (L)ogical" do
108
+ column = DBF::Column.new "ColumnName", "L", 1, 0
109
+ column.schema_definition.should == "\"column_name\", :boolean\n"
110
+ end
111
+
112
+ it "should define a text column if type is (M)emo" do
113
+ column = DBF::Column.new "ColumnName", "M", 1, 0
114
+ column.schema_definition.should == "\"column_name\", :text\n"
115
+ end
116
+
117
+ it "should define a string column with length for any other data types" do
118
+ column = DBF::Column.new "ColumnName", "X", 20, 0
119
+ column.schema_definition.should == "\"column_name\", :string, :limit => 20\n"
120
+ end
121
+ end
122
+
123
+ context "#strip_non_ascii_chars" do
124
+ before do
125
+ @column = DBF::Column.new "ColumnName", "N", 1, 0
126
+ end
127
+
128
+ it "should strip characters below decimal 32 and above decimal 127" do
129
+ @column.strip_non_ascii_chars("--\x1F-\x68\x65\x6C\x6C\x6F world-\x80--").should == "---hello world---"
130
+ end
131
+
132
+ it "should truncate characters with decimal 0" do
133
+ @column.strip_non_ascii_chars("--\x1F-\x68\x65\x6C\x6C\x6F \x00 world-\x80--").should == "---hello "
134
+ end
135
+ end
136
+
137
+ end
@@ -0,0 +1,124 @@
1
+ require File.dirname(__FILE__) + "/../spec_helper"
2
+
3
+ describe DBF::Record do
4
+
5
+ def example_record(data = '')
6
+ table = mock_table(data)
7
+ DBF::Record.new(table)
8
+ end
9
+
10
+ def mock_table(data = '')
11
+ @column1 = DBF::Column.new 'ColumnName', 'N', 1, 0
12
+
13
+ returning mock('table') do |table|
14
+ table.stub!(:memo_block_size).and_return(8)
15
+ table.stub!(:memo).and_return(nil)
16
+ table.stub!(:columns).and_return([@column1])
17
+ table.stub!(:data).and_return(data)
18
+ table.stub!(:has_memo_file?).and_return(true)
19
+ table.data.stub!(:read).and_return(data)
20
+ end
21
+ end
22
+
23
+ context "when initialized" do
24
+ it "should typecast number columns no decimal places to Integer" do
25
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
26
+ table.column("ID").type.should == "N"
27
+ table.column("ID").decimal.should == 0
28
+ table.record(1).attributes['id'].should be_kind_of(Integer)
29
+ end
30
+
31
+ it "should typecast number columns with decimals > 0 to Float" do
32
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
33
+ table.column("ID").type.should == "N"
34
+ table.column("COST").decimal.should == 2
35
+ table.record(1).attributes['cost'].should be_kind_of(Float)
36
+ end
37
+
38
+ it "should typecast memo columns to String" do
39
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
40
+ table.column("DESC").type.should == "M"
41
+ table.record(1).attributes['desc'].should be_kind_of(String)
42
+ end
43
+
44
+ it "should typecast logical columns to True or False" do
45
+ table = DBF::Table.new "#{DB_PATH}/dbase_30.dbf"
46
+ table.column("WEBINCLUDE").type.should == "L"
47
+ table.record(1).attributes["webinclude"].should satisfy {|v| v == true || v == false}
48
+ end
49
+ end
50
+
51
+ describe '#memo_block_content_size' do
52
+ it "should equal the difference between the table's memo_block_size and 8" do
53
+ table = mock_table
54
+ table.should_receive(:memo_block_size).and_return(128)
55
+ record = DBF::Record.new(table)
56
+
57
+ record.send(:memo_block_content_size).should == 120
58
+ end
59
+ end
60
+
61
+ describe '#memo_content_size' do
62
+ it "should equal 8 plus the difference between memo_size and the table's memo_block_size" do
63
+ record = example_record
64
+ record.should_receive(:memo_block_size).and_return(8)
65
+
66
+ record.send(:memo_content_size, 1024).should == 1024
67
+ end
68
+ end
69
+
70
+ describe '#read_memo' do
71
+ it 'should return nil if start_block is less than 1' do
72
+ table = mock_table
73
+ record = DBF::Record.new(table)
74
+
75
+ record.send(:read_memo, 0).should be_nil
76
+ record.send(:read_memo, -1).should be_nil
77
+ end
78
+
79
+ it 'should return nil if memo file is missing' do
80
+ table = mock_table
81
+ table.should_receive(:has_memo_file?).and_return(false)
82
+ record = DBF::Record.new(table)
83
+
84
+ record.send(:read_memo, 5).should be_nil
85
+ end
86
+ end
87
+
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.record(9)
92
+ record.to_a.should == ["Ten records stored in this database", 10.0, nil, false, "0.100000000000000000", nil]
93
+ end
94
+ end
95
+
96
+ describe '#==' do
97
+ before do
98
+ @record = example_record
99
+ end
100
+
101
+ it 'should be false if other does not have attributes' do
102
+ other = mock('object')
103
+ (@record == other).should be_false
104
+ end
105
+
106
+ it 'should be true if other attributes match' do
107
+ attributes = {:x => 1, :y => 2}
108
+ @record.stub!(:attributes).and_return(attributes)
109
+ other = mock('object', :attributes => attributes)
110
+ (@record == other).should be_true
111
+ end
112
+ end
113
+
114
+ describe 'unpack_data' do
115
+ before do
116
+ @record = example_record('abc')
117
+ end
118
+
119
+ it 'should unpack the data' do
120
+ @record.send(:unpack_data, 3).should == 'abc'
121
+ end
122
+ end
123
+
124
+ end
@@ -0,0 +1,227 @@
1
+ require File.dirname(__FILE__) + "/../spec_helper"
2
+
3
+ describe DBF::Table do
4
+ context "when initialized" do
5
+ before do
6
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
7
+ end
8
+
9
+ it "should load the data file" do
10
+ @table.data.should be_kind_of(File)
11
+ end
12
+
13
+ it "should load the memo file" do
14
+ @table.has_memo_file?.should be_true
15
+ @table.instance_eval("@memo").should be_kind_of(File)
16
+ end
17
+
18
+ it "should determine the memo file format" do
19
+ @table.memo_file_format.should == :dbt
20
+ end
21
+
22
+ it "should determine the correct memo block size" do
23
+ @table.memo_block_size.should == 512
24
+ end
25
+
26
+ it "should determine the number of columns in each record" do
27
+ @table.columns.size.should == 15
28
+ end
29
+
30
+ it "should determine the number of records in the database" do
31
+ @table.record_count.should == 67
32
+ end
33
+
34
+ it "should determine the database version" do
35
+ @table.version.should == "83"
36
+ end
37
+ end
38
+
39
+ context "when closed" do
40
+ before do
41
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
42
+ end
43
+
44
+ it "should close the data file" do
45
+ @table.close
46
+ lambda { @table.record(1) }.should raise_error(IOError)
47
+ end
48
+ end
49
+
50
+ describe "#column" do
51
+ it "should accept a string or symbol as input" do
52
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
53
+ table.column(:IMAGE).should be_kind_of(DBF::Column)
54
+ table.column("IMAGE").should be_kind_of(DBF::Column)
55
+ end
56
+
57
+ it "should return a DBF::Field object when the column_name exists" do
58
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
59
+ table.column(:IMAGE).should be_kind_of(DBF::Column)
60
+ end
61
+
62
+ it "should return nil when the column_name does not exist" do
63
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
64
+ table.column(:NOTANIMAGE).should be_nil
65
+ end
66
+ end
67
+
68
+ describe "#schema" do
69
+ it "should match the test schema fixture" do
70
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
71
+ control_schema = File.read(File.dirname(__FILE__) + '/../fixtures/dbase_83_schema.txt')
72
+
73
+ table.schema.should == control_schema
74
+ end
75
+ end
76
+
77
+ describe "#version_description" do
78
+ it "should return a text description of the database type" do
79
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
80
+ table.version_description.should == "dBase III with memo file"
81
+ end
82
+ end
83
+
84
+ describe '#replace_extname' do
85
+ it "should change the file extension" do
86
+ table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
87
+ table.send(:replace_extname, 'dbase_83.dbf', 'fpt').should == 'dbase_83.fpt'
88
+ end
89
+ end
90
+
91
+ describe '#to_a' do
92
+ before do
93
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
94
+
95
+ @records = []
96
+ @table.each {|record| @records << record}
97
+ end
98
+
99
+ it 'should return an array of records' do
100
+ @table.to_a.should == @records
101
+ end
102
+ end
103
+
104
+ describe '#to_csv' do
105
+ before do
106
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
107
+ end
108
+
109
+ after do
110
+ FileUtils.rm_f 'dbase_83.csv'
111
+ FileUtils.rm_f 'test.csv'
112
+ end
113
+
114
+ it 'should create default dump.csv' do
115
+ @table.to_csv
116
+ File.exists?('dbase_83.csv').should be_true
117
+ end
118
+
119
+ it 'should create custom csv file' do
120
+ @table.to_csv('test.csv')
121
+ File.exists?('test.csv').should be_true
122
+ end
123
+ end
124
+
125
+ describe "#record" do
126
+ before do
127
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
128
+
129
+ @records = []
130
+ @table.each {|record| @records << record}
131
+ end
132
+
133
+ it "should nullify deleted records" do
134
+ @table.stub!(:deleted_record?).and_return(true)
135
+ @table.record(5).should be_nil
136
+ end
137
+ end
138
+
139
+ describe "#find" do
140
+ describe "with index" do
141
+ before do
142
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
143
+ end
144
+
145
+ it "should return the correct record" do
146
+ @table.find(5).should == @table.record(5)
147
+ end
148
+ end
149
+
150
+ describe 'with array of indexes' do
151
+ before do
152
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
153
+ end
154
+
155
+ it "should return the correct records" do
156
+ @table.find([1, 5, 10]).should == [@table.record(1), @table.record(5), @table.record(10)]
157
+ end
158
+ end
159
+
160
+ describe "with :all" do
161
+ before do
162
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
163
+
164
+ @records = []
165
+ @table.each {|record| @records << record}
166
+ end
167
+
168
+ it "should accept a block" do
169
+ records = []
170
+ @table.find(:all, :weight => 0.0) do |record|
171
+ records << record
172
+ end
173
+ records.should == @table.find(:all, :weight => 0.0)
174
+ end
175
+
176
+ it "should return all records if options are empty" do
177
+ @table.find(:all).should == @records
178
+ end
179
+
180
+ it "should return matching records when used with options" do
181
+ @table.find(:all, "WEIGHT" => 0.0).should == @records.select {|r| r.attributes["weight"] == 0.0}
182
+ end
183
+
184
+ it "should AND multiple search terms" do
185
+ @table.find(:all, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should == []
186
+ end
187
+
188
+ it "should match original column names" do
189
+ @table.find(:all, "WEIGHT" => 0.0).should_not be_empty
190
+ end
191
+
192
+ it "should match symbolized column names" do
193
+ @table.find(:all, :WEIGHT => 0.0).should_not be_empty
194
+ end
195
+
196
+ it "should match downcased column names" do
197
+ @table.find(:all, "weight" => 0.0).should_not be_empty
198
+ end
199
+
200
+ it "should match symbolized downcased column names" do
201
+ @table.find(:all, :weight => 0.0).should_not be_empty
202
+ end
203
+ end
204
+
205
+ describe "with :first" do
206
+ before do
207
+ @table = DBF::Table.new "#{DB_PATH}/dbase_83.dbf"
208
+
209
+ @records = []
210
+ @table.each {|record| @records << record}
211
+ end
212
+
213
+ it "should return the first record if options are empty" do
214
+ @table.find(:first).should == @records.first
215
+ end
216
+
217
+ it "should return the first matching record when used with options" do
218
+ @table.find(:first, "CODE" => "C").should == @records[5]
219
+ end
220
+
221
+ it "should AND multiple search terms" do
222
+ @table.find(:first, "ID" => 30, "IMAGE" => "graphics/00000001/TBC01.jpg").should be_nil
223
+ end
224
+ end
225
+ end
226
+
227
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-adt
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 5
8
+ - 0
9
+ version: 0.5.0
10
+ platform: ruby
11
+ authors:
12
+ - Chase Gray
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-02-23 00:00:00 -05:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: activesupport
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ segments:
28
+ - 2
29
+ - 1
30
+ - 0
31
+ version: 2.1.0
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ description: A small fast library for reading Advantage Database Server database files (ADT).
35
+ email: chase@ratchetsoftware.com
36
+ executables: []
37
+
38
+ extensions: []
39
+
40
+ extra_rdoc_files:
41
+ - README.markdown
42
+ files:
43
+ - README.markdown
44
+ - Rakefile
45
+ - VERSION
46
+ - adt_format_notes.txt
47
+ - lib/adt.rb
48
+ - lib/adt/column.rb
49
+ - lib/adt/globals.rb
50
+ - lib/adt/record.rb
51
+ - lib/adt/table.rb
52
+ - spec/fixtures/A.ADT
53
+ - spec/fixtures/test.adt
54
+ - spec/functional/adt_shared.rb
55
+ - spec/spec_helper.rb
56
+ - spec/unit/column_spec.rb
57
+ - spec/unit/record_spec.rb
58
+ - spec/unit/table_spec.rb
59
+ has_rdoc: true
60
+ homepage: http://github.com/chasemgray/Ruby-ADT
61
+ licenses: []
62
+
63
+ post_install_message:
64
+ rdoc_options:
65
+ - --charset=UTF-8
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ segments:
73
+ - 0
74
+ version: "0"
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ segments:
80
+ - 0
81
+ version: "0"
82
+ requirements: []
83
+
84
+ rubyforge_project:
85
+ rubygems_version: 1.3.6
86
+ signing_key:
87
+ specification_version: 3
88
+ summary: Read ADT files
89
+ test_files:
90
+ - spec/functional/adt_shared.rb
91
+ - spec/spec_helper.rb
92
+ - spec/unit/column_spec.rb
93
+ - spec/unit/record_spec.rb
94
+ - spec/unit/table_spec.rb