dbf 5.1.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +19 -9
  4. data/bin/dbf +2 -54
  5. data/dbf.gemspec +6 -9
  6. data/lib/dbf/cli.rb +95 -0
  7. data/lib/dbf/column.rb +17 -15
  8. data/lib/dbf/column_builder.rb +31 -0
  9. data/lib/dbf/column_type.rb +66 -16
  10. data/lib/dbf/database/foxpro.rb +19 -32
  11. data/lib/dbf/encoder.rb +116 -0
  12. data/lib/dbf/encodings.rb +8 -0
  13. data/lib/dbf/file_handler.rb +36 -0
  14. data/lib/dbf/find.rb +54 -0
  15. data/lib/dbf/header.rb +5 -8
  16. data/lib/dbf/memo/base.rb +2 -0
  17. data/lib/dbf/memo/dbase3.rb +2 -2
  18. data/lib/dbf/memo/dbase4.rb +2 -2
  19. data/lib/dbf/memo/foxpro.rb +14 -7
  20. data/lib/dbf/record.rb +60 -34
  21. data/lib/dbf/record_context.rb +5 -0
  22. data/lib/dbf/record_iterator.rb +35 -0
  23. data/lib/dbf/schema.rb +21 -21
  24. data/lib/dbf/table.rb +50 -181
  25. data/lib/dbf/version.rb +1 -1
  26. data/lib/dbf/version_config.rb +79 -0
  27. data/lib/dbf.rb +8 -0
  28. metadata +17 -64
  29. data/spec/dbf/column_spec.rb +0 -287
  30. data/spec/dbf/database/foxpro_spec.rb +0 -53
  31. data/spec/dbf/encoding_spec.rb +0 -49
  32. data/spec/dbf/file_formats_spec.rb +0 -221
  33. data/spec/dbf/record_spec.rb +0 -116
  34. data/spec/dbf/table_spec.rb +0 -377
  35. data/spec/fixtures/cp1251.dbf +0 -0
  36. data/spec/fixtures/cp1251_summary.txt +0 -12
  37. data/spec/fixtures/dbase_02.dbf +0 -0
  38. data/spec/fixtures/dbase_02_summary.txt +0 -23
  39. data/spec/fixtures/dbase_03.dbf +0 -0
  40. data/spec/fixtures/dbase_03_cyrillic.dbf +0 -0
  41. data/spec/fixtures/dbase_03_cyrillic_summary.txt +0 -11
  42. data/spec/fixtures/dbase_03_summary.txt +0 -40
  43. data/spec/fixtures/dbase_30.dbf +0 -0
  44. data/spec/fixtures/dbase_30.fpt +0 -0
  45. data/spec/fixtures/dbase_30_summary.txt +0 -154
  46. data/spec/fixtures/dbase_31.dbf +0 -0
  47. data/spec/fixtures/dbase_31_summary.txt +0 -20
  48. data/spec/fixtures/dbase_32.dbf +0 -0
  49. data/spec/fixtures/dbase_32_summary.txt +0 -11
  50. data/spec/fixtures/dbase_83.dbf +0 -0
  51. data/spec/fixtures/dbase_83.dbt +0 -0
  52. data/spec/fixtures/dbase_83_missing_memo.dbf +0 -0
  53. data/spec/fixtures/dbase_83_missing_memo_record_0.yml +0 -16
  54. data/spec/fixtures/dbase_83_record_0.yml +0 -16
  55. data/spec/fixtures/dbase_83_record_9.yml +0 -23
  56. data/spec/fixtures/dbase_83_schema_ar.txt +0 -19
  57. data/spec/fixtures/dbase_83_schema_sq.txt +0 -21
  58. data/spec/fixtures/dbase_83_schema_sq_lim.txt +0 -21
  59. data/spec/fixtures/dbase_83_summary.txt +0 -24
  60. data/spec/fixtures/dbase_8b.dbf +0 -0
  61. data/spec/fixtures/dbase_8b.dbt +0 -0
  62. data/spec/fixtures/dbase_8b_summary.txt +0 -15
  63. data/spec/fixtures/dbase_8c.dbf +0 -0
  64. data/spec/fixtures/dbase_f5.dbf +0 -0
  65. data/spec/fixtures/dbase_f5.fpt +0 -0
  66. data/spec/fixtures/dbase_f5_summary.txt +0 -68
  67. data/spec/fixtures/foxprodb/FOXPRO-DB-TEST.DBC +0 -0
  68. data/spec/fixtures/foxprodb/FOXPRO-DB-TEST.DCT +0 -0
  69. data/spec/fixtures/foxprodb/FOXPRO-DB-TEST.DCX +0 -0
  70. data/spec/fixtures/foxprodb/calls.CDX +0 -0
  71. data/spec/fixtures/foxprodb/calls.FPT +0 -0
  72. data/spec/fixtures/foxprodb/calls.dbf +0 -0
  73. data/spec/fixtures/foxprodb/contacts.CDX +0 -0
  74. data/spec/fixtures/foxprodb/contacts.FPT +0 -0
  75. data/spec/fixtures/foxprodb/contacts.dbf +0 -0
  76. data/spec/fixtures/foxprodb/setup.CDX +0 -0
  77. data/spec/fixtures/foxprodb/setup.dbf +0 -0
  78. data/spec/fixtures/foxprodb/types.CDX +0 -0
  79. data/spec/fixtures/foxprodb/types.dbf +0 -0
  80. data/spec/fixtures/polygon.dbf +0 -0
  81. data/spec/spec_helper.rb +0 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d565a51490240e9f5ed724970a568f12641b595b66e426a3a4a1a6ba0c84003
4
- data.tar.gz: 12fc66ce2b83c55157532d252b52eb6a343174898a7965a636beb7877aa29c17
3
+ metadata.gz: d2d1ca393ae41262fd052c74ffe0ec497a963a2c036797686061312a0c492888
4
+ data.tar.gz: 7e92c62ace7713b883b6b2e925c34f7a8114ebd6345b36a81f97fb47109711e2
5
5
  SHA512:
6
- metadata.gz: c2de237f84ddc71f271a782b537f03d10a8586e939ad08f66b34c9f6bcc38a1b34aa4a2899d3248a1ff2f574bfb9920c19d2f134cae01ac66f0e228acd9a1c13
7
- data.tar.gz: 5b5b3ee469e5afe32bc54ea4f494b543c675a2b4c61b6e6cf56af68ab2ab989a35644b7795a29e1cd9e4dfe8e4129c1abf85696a4ff7df8fb0444f89c7f80193
6
+ metadata.gz: f0f3b1b02ab53f505ab3efc1dff89d2ad1169698ef7d9443b3eec3b758b921ae2df51bf443d4e877ac072bdef87c547607d1d39d913a5c361b4c31e21fee4b07
7
+ data.tar.gz: 6d826dd35aa6e80b671aeb804dc9991776851bbb9ca0ff5fb9ed6cd7139cbd40b0bad68327fc8f17b03b9ba2f2b77479331b7ad998c53e7ee32e5caee59241e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## main branch
4
+
5
+ - Add support for 8 more code pages (issue #98): Mazovia cp620 and Kamenický cp895 via vendored translation tables (new DBF::Encoder), plus macRoman, cp1255, cp1256, macCyrillic, macCentEuro and macGreek
6
+ - Blank Visual FoxPro "T" (DateTime) columns now return nil instead of a Julian day-0 date
7
+ - Blank "F" (Float) columns now return nil instead of 0.0, matching "N" (Number) behavior
8
+ - Extract CLI logic into DBF::CLI class (bin/dbf is now a thin shim)
9
+ - DBF::Table#to_csv now accepts an IO in addition to a String path
10
+ - Fix broken link in README
11
+
12
+ ## 5.2.0
13
+
14
+ - Drop support for Ruby 3.1 and 3.2
15
+
3
16
  ## 5.1.1
4
17
 
5
18
  - Frozen string literals
data/README.md CHANGED
@@ -10,16 +10,18 @@
10
10
  DBF is a small, fast Ruby library for reading dBase, xBase, Clipper, and FoxPro database files.
11
11
 
12
12
  * Project page: <https://github.com/infused/dbf>
13
- * API Documentation: <https://rubydoc.info/github/infused/dbf>
13
+ * API Documentation: <https://rdoc.info/gems/dbf>
14
14
  * Report bugs: <https://github.com/infused/dbf/issues>
15
15
  * Questions: Email <mailto:keithm@infused.org> and put DBF somewhere in the
16
16
  subject line
17
17
  * Change log: <https://github.com/infused/dbf/blob/main/CHANGELOG.md>
18
18
 
19
+ NOTE: Beginning with version 5.2 we have dropped support for Ruby 3.2 and earlier.
20
+
19
21
  NOTE: Beginning with version 4.3 we have dropped support for Ruby 3.0 and earlier.
20
22
 
21
23
  NOTE: Beginning with version 4 we have dropped support for Ruby 2.0, 2.1, 2.2, and 2.3. If you need support for these older Rubies,
22
- please use 3.0.x (<https://github.com/infused.org/dbf/tree/3_stable>)
24
+ please use 3.0.x (<https://github.com/infused/dbf/tree/3_stable>)
23
25
 
24
26
  NOTE: Beginning with version 3 we have dropped support for Ruby 1.8 and 1.9. If you need support for older Rubies,
25
27
  please use 2.0.x (<https://github.com/infused/dbf/tree/2_stable>)
@@ -28,7 +30,7 @@ please use 2.0.x (<https://github.com/infused/dbf/tree/2_stable>)
28
30
 
29
31
  DBF is tested to work with the following versions of Ruby:
30
32
 
31
- * Ruby 3.1.x, 3.2.x, 3.3.x
33
+ * Ruby 3.3.x, 3.4.x, 4.0.x
32
34
 
33
35
  ## Installation
34
36
 
@@ -149,6 +151,7 @@ table = DBF::Table.new('dbf/books.dbf', nil, 'cp866')
149
151
  | 01 | cp437 | U.S. MS–DOS |
150
152
  | 02 | cp850 | International MS–DOS |
151
153
  | 03 | cp1252 | Windows ANSI |
154
+ | 04 | macRoman | Standard Macintosh |
152
155
  | 08 | cp865 | Danish OEM |
153
156
  | 09 | cp437 | Dutch OEM |
154
157
  | 0a | cp850 | Dutch OEM* |
@@ -189,6 +192,8 @@ table = DBF::Table.new('dbf/books.dbf', nil, 'cp866')
189
192
  | 65 | cp866 | Russian MS–DOS |
190
193
  | 66 | cp865 | Nordic MS–DOS |
191
194
  | 67 | cp861 | Icelandic MS–DOS |
195
+ | 68 | cp895 | Kamenický (Czech) MS–DOS |
196
+ | 69 | cp620 | Mazovia (Polish) MS–DOS |
192
197
  | 6a | cp737 | Greek MS–DOS (437G) |
193
198
  | 6b | cp857 | Turkish MS–DOS |
194
199
  | 6c | cp863 | French–Canadian MS–DOS |
@@ -197,9 +202,14 @@ table = DBF::Table.new('dbf/books.dbf', nil, 'cp866')
197
202
  | 7a | cp936 | PRC GBK |
198
203
  | 7b | cp932 | Japanese Shift-JIS |
199
204
  | 7c | cp874 | Thai Windows/MS–DOS |
205
+ | 7d | cp1255 | Hebrew Windows |
206
+ | 7e | cp1256 | Arabic Windows |
200
207
  | 86 | cp737 | Greek OEM |
201
208
  | 87 | cp852 | Slovenian OEM |
202
209
  | 88 | cp857 | Turkish OEM |
210
+ | 96 | macCyrillic | Russian Macintosh |
211
+ | 97 | macCentEuro | Macintosh EE |
212
+ | 98 | macGreek | Greek Macintosh |
203
213
  | c8 | cp1250 | Eastern European Windows |
204
214
  | c9 | cp1251 | Russian Windows |
205
215
  | ca | cp1254 | Turkish Windows |
@@ -251,7 +261,7 @@ class Book < Sequel::Model; end
251
261
  Sequel.migration do
252
262
  up do
253
263
  table = DBF::Table.new('db/dbf/books.dbf')
254
- eval(table.schema(:sequel, true)) # passing true to limit output to create_table() only
264
+ eval(table.schema(:sequel, table_only: true)) # limit output to create_table() only
255
265
 
256
266
  Book.reset_column_information
257
267
  table.each do |record|
@@ -326,16 +336,16 @@ at this time.
326
336
  | 04 | dBase IV without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
327
337
  | 05 | dBase V without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
328
338
  | 07 | Visual Objects 1.x | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
329
- | 30 | Visual FoxPro | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | - |
330
- | 31 | Visual FoxPro with AutoIncrement | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
331
- | 32 | Visual FoxPro with field type Varchar or Varbinary | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
339
+ | 30 | Visual FoxPro | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | - |
340
+ | 31 | Visual FoxPro with AutoIncrement | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
341
+ | 32 | Visual FoxPro with field type Varchar or Varbinary | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
332
342
  | 7b | dBase IV with memo file | Y | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - |
333
343
  | 83 | dBase III with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - |
334
344
  | 87 | Visual Objects 1.x with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - |
335
345
  | 8b | dBase IV with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | N | - | - | - |
336
346
  | 8e | dBase IV with SQL table | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | N | - | - | - |
337
- | f5 | FoxPro with memo file | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
338
- | fb | FoxPro without memo file | Y | Y | Y | Y | - | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
347
+ | f5 | FoxPro with memo file | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
348
+ | fb | FoxPro without memo file | Y | Y | Y | Y | - | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
339
349
 
340
350
  Data type descriptions
341
351
 
data/bin/dbf CHANGED
@@ -1,60 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  Signal.trap('PIPE', 'SYSTEM_DEFAULT')
4
5
 
5
6
  require 'dbf'
6
- require 'dbf/version'
7
- require 'optparse'
8
7
 
9
- params = ARGV.getopts('h', 's', 'a', 'c', 'r', 'v')
10
-
11
- if params['v']
12
- puts "dbf version: #{DBF::VERSION}"
13
-
14
- elsif params['h']
15
- puts "usage: #{File.basename(__FILE__)} [-h|-s|-a|-c|-r] filename"
16
- puts ' -h = print this message'
17
- puts ' -v = print the DBF gem version'
18
- puts ' -s = print summary information'
19
- puts ' -a = create an ActiveRecord::Schema'
20
- puts ' -r = create a Sequel migration'
21
- puts ' -c = export as CSV'
22
- else
23
-
24
- filename = ARGV.shift
25
- abort 'You must supply a filename on the command line' unless filename
26
-
27
- # create an ActiveRecord::Schema
28
- if params['a']
29
- table = DBF::Table.new filename
30
- puts table.schema(:activerecord)
31
- end
32
-
33
- # create an Sequel::Migration
34
- if params['r']
35
- table = DBF::Table.new filename
36
- puts table.schema(:sequel)
37
- end
38
-
39
- if params['s']
40
- table = DBF::Table.new filename
41
- puts
42
- puts "Database: #{filename}"
43
- puts "Type: (#{table.version}) #{table.version_description}"
44
- puts "Encoding: #{table.header_encoding}" if table.header_encoding
45
- puts "Memo File: #{table.has_memo_file? ? 'true' : 'false'}"
46
- puts "Records: #{table.record_count}"
47
-
48
- puts "\nFields:"
49
- puts 'Name Type Length Decimal'
50
- puts '-' * 78
51
- table.columns.each do |f|
52
- puts format('%-16s %-10s %-10s %-10s', f.name, f.type, f.length, f.decimal)
53
- end
54
- end
55
-
56
- if params['c']
57
- table = DBF::Table.new filename
58
- table.to_csv
59
- end
60
- end
8
+ exit DBF::CLI.run(ARGV)
data/dbf.gemspec CHANGED
@@ -1,24 +1,21 @@
1
- lib = File.expand_path('lib', __dir__)
2
- $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3
- require 'dbf/version'
1
+ require_relative 'lib/dbf/version'
4
2
 
5
3
  Gem::Specification.new do |s|
6
4
  s.name = 'dbf'
7
5
  s.version = DBF::VERSION
8
6
  s.authors = ['Keith Morrison']
9
7
  s.email = 'keithm@infused.org'
10
- s.homepage = 'http://github.com/infused/dbf'
8
+ s.homepage = 'https://github.com/infused/dbf'
11
9
  s.summary = 'Read xBase files'
12
10
  s.description = 'A small fast library for reading dBase, xBase, Clipper and FoxPro database files.'
13
11
  s.license = 'MIT'
14
12
  s.bindir = 'bin'
15
13
  s.executables = ['dbf']
16
- s.rdoc_options = ['--charset=UTF-8']
17
- s.extra_rdoc_files = ['README.md', 'CHANGELOG.md', 'LICENSE']
18
- s.files = Dir['README.md', 'CHANGELOG.md', 'LICENSE', '{bin,lib,spec}/**/*', 'dbf.gemspec']
14
+ s.files = Dir['README.md', 'CHANGELOG.md', 'LICENSE', '{bin,lib}/**/*', 'dbf.gemspec']
19
15
  s.require_paths = ['lib']
20
- s.required_rubygems_version = Gem::Requirement.new('>= 3.2.3')
21
- s.required_ruby_version = Gem::Requirement.new('>= 3.1.0')
16
+ s.required_ruby_version = '>= 3.3.0'
22
17
  s.metadata['rubygems_mfa_required'] = 'true'
18
+ s.metadata['source_code_uri'] = 'https://github.com/infused/dbf'
19
+ s.metadata['changelog_uri'] = 'https://github.com/infused/dbf/blob/main/CHANGELOG.md'
23
20
  s.add_dependency 'csv'
24
21
  end
data/lib/dbf/cli.rb ADDED
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module DBF
6
+ class CLI
7
+ USAGE = <<~HELP
8
+ usage: dbf [-h|-s|-a|-c|-r] filename
9
+ -h = print this message
10
+ -v = print the DBF gem version
11
+ -s = print summary information
12
+ -a = create an ActiveRecord::Schema
13
+ -r = create a Sequel migration
14
+ -c = export as CSV
15
+ HELP
16
+
17
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
18
+ new(argv, stdout: stdout, stderr: stderr).run
19
+ end
20
+
21
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
22
+ @argv = argv.dup
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ end
26
+
27
+ def run
28
+ params = OptionParser.new.getopts(@argv, 'h', 's', 'a', 'c', 'r', 'v')
29
+
30
+ if params['v']
31
+ print_version
32
+ elsif params['h']
33
+ print_help
34
+ else
35
+ filename = @argv.shift
36
+ return missing_filename unless filename
37
+
38
+ action = %w[a r s c].find { |flag| params[flag] }
39
+ case action
40
+ when 'a' then print_ar_schema(filename)
41
+ when 'r' then print_sequel_schema(filename)
42
+ when 's' then print_summary(filename)
43
+ when 'c' then print_csv(filename)
44
+ end
45
+ end
46
+ 0
47
+ rescue DBF::FileNotFoundError => e
48
+ @stderr.puts "DBF::FileNotFoundError: #{e.message}"
49
+ 1
50
+ end
51
+
52
+ private
53
+
54
+ def print_version
55
+ @stdout.puts "dbf version: #{DBF::VERSION}"
56
+ end
57
+
58
+ def print_help
59
+ @stdout.puts USAGE
60
+ end
61
+
62
+ def missing_filename
63
+ @stderr.puts 'You must supply a filename on the command line'
64
+ 1
65
+ end
66
+
67
+ def print_ar_schema(filename)
68
+ @stdout.puts DBF::Table.new(filename).schema(:activerecord)
69
+ end
70
+
71
+ def print_sequel_schema(filename)
72
+ @stdout.puts DBF::Table.new(filename).schema(:sequel)
73
+ end
74
+
75
+ def print_summary(filename)
76
+ table = DBF::Table.new(filename)
77
+ @stdout.puts
78
+ @stdout.puts "Database: #{filename}"
79
+ @stdout.puts "Type: (#{table.version}) #{table.version_description}"
80
+ @stdout.puts "Encoding: #{table.header_encoding}" if table.header_encoding
81
+ @stdout.puts "Memo File: #{table.has_memo_file? ? 'true' : 'false'}"
82
+ @stdout.puts "Records: #{table.record_count}"
83
+ @stdout.puts "\nFields:"
84
+ @stdout.puts 'Name Type Length Decimal'
85
+ @stdout.puts '-' * 78
86
+ table.columns.each do |f|
87
+ @stdout.puts format('%-16s %-10s %-10s %-10s', f.name, f.type, f.length, f.decimal)
88
+ end
89
+ end
90
+
91
+ def print_csv(filename)
92
+ DBF::Table.new(filename).to_csv(@stdout)
93
+ end
94
+ end
95
+ end
data/lib/dbf/column.rb CHANGED
@@ -2,17 +2,13 @@
2
2
 
3
3
  module DBF
4
4
  class Column
5
- extend Forwardable
6
-
7
5
  class LengthError < StandardError
8
6
  end
9
7
 
10
8
  class NameError < StandardError
11
9
  end
12
10
 
13
- attr_reader :table, :name, :type, :length, :decimal, :encoding
14
-
15
- def_delegator :type_cast_class, :type_cast
11
+ attr_reader :name, :type, :length, :decimal
16
12
 
17
13
  # rubocop:disable Style/MutableConstant
18
14
  TYPE_CAST_CLASS = {
@@ -26,7 +22,7 @@ module DBF
26
22
  M: ColumnType::Memo,
27
23
  B: ColumnType::Double,
28
24
  G: ColumnType::General,
29
- :+ => ColumnType::SignedLong2
25
+ :+ => ColumnType::AutoIncrement
30
26
  }
31
27
  # rubocop:enable Style/MutableConstant
32
28
  TYPE_CAST_CLASS.default = ColumnType::String
@@ -40,31 +36,37 @@ module DBF
40
36
  # @param length [Integer]
41
37
  # @param decimal [Integer]
42
38
  def initialize(table, name, type, length, decimal)
43
- @encoding = table.encoding
44
-
45
39
  @table = table
46
40
  @name = clean(name)
47
41
  @type = type
48
42
  @length = length
49
43
  @decimal = decimal
50
- @version = table.version
51
44
 
52
45
  validate_length
53
46
  validate_name
54
47
  end
55
48
 
56
- # Returns true if the column is a memo
49
+ def encoding = @table.encoding
50
+
51
+ # @param value [String]
52
+ def type_cast(value)
53
+ type_cast_class.type_cast(value)
54
+ end
55
+
56
+ # Decodes a raw column value, handling memo, blank, and type cast cases
57
57
  #
58
- # @return [Boolean]
59
- def memo?
60
- @memo ||= type == 'M'
58
+ # @param raw [String]
59
+ # @yield [raw] for memo column resolution
60
+ # @return decoded value
61
+ def decode(raw, &)
62
+ type_cast_class.decode(raw, &)
61
63
  end
62
64
 
63
65
  # Returns a Hash with :name, :type, :length, and :decimal keys
64
66
  #
65
67
  # @return [Hash]
66
68
  def to_hash
67
- {name: name, type: type, length: length, decimal: decimal}
69
+ {name:, type:, length:, decimal:}
68
70
  end
69
71
 
70
72
  # Underscored name
@@ -80,7 +82,7 @@ module DBF
80
82
  private
81
83
 
82
84
  def clean(value) # :nodoc:
83
- table.encode_string(value.strip.partition("\x00").first)
85
+ @table.encode_string(value.strip.split("\x00", 2).first || +'')
84
86
  end
85
87
 
86
88
  def type_cast_class # :nodoc:
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBF
4
+ class ColumnBuilder
5
+ def initialize(table, data, version_config)
6
+ @table = table
7
+ @data = data
8
+ @version_config = version_config
9
+ end
10
+
11
+ def build
12
+ safe_seek do
13
+ @data.seek(@version_config.header_size)
14
+ [].tap do |columns|
15
+ columns << Column.new(*@version_config.read_column_args(@table, @data)) until end_of_record?
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def end_of_record?
23
+ safe_seek { @data.read(1).ord == 13 }
24
+ end
25
+
26
+ def safe_seek
27
+ original_pos = @data.pos
28
+ yield.tap { @data.seek(original_pos) }
29
+ end
30
+ end
31
+ end
@@ -5,12 +5,27 @@ module DBF
5
5
  class Base
6
6
  attr_reader :decimal, :encoding
7
7
 
8
- # @param decimal [Integer]
9
- # @param encoding [String, Encoding]
8
+ # @param column [DBF::Column]
10
9
  def initialize(column)
11
10
  @decimal = column.decimal
12
11
  @encoding = column.encoding
13
12
  end
13
+
14
+ def blank_value
15
+ nil
16
+ end
17
+
18
+ def skip_blank?
19
+ false
20
+ end
21
+
22
+ def decode(raw, &)
23
+ if skip_blank? && raw.count(' ') == raw.length
24
+ blank_value
25
+ else
26
+ type_cast(raw)
27
+ end
28
+ end
14
29
  end
15
30
 
16
31
  class Nil < Base
@@ -21,11 +36,13 @@ module DBF
21
36
  end
22
37
 
23
38
  class Number < Base
39
+ def skip_blank? = true
40
+
24
41
  # @param value [String]
25
42
  def type_cast(value)
26
- return nil if value.strip.empty?
43
+ return nil if value.empty?
27
44
 
28
- @decimal.zero? ? value.to_i : value.to_f
45
+ decimal.zero? ? value.to_i : value.to_f
29
46
  end
30
47
  end
31
48
 
@@ -43,18 +60,22 @@ module DBF
43
60
  end
44
61
  end
45
62
 
46
- class SignedLong2 < Base
63
+ class AutoIncrement < Base
47
64
  # @param value [String]
48
65
  def type_cast(value)
49
- s = value.unpack1('B*')
50
- sign_multiplier = s[0] == '0' ? -1 : 1
51
- s[1, 31].to_i(2) * sign_multiplier
66
+ bits = value.unpack1('B*')
67
+ sign_multiplier = bits[0] == '0' ? -1 : 1
68
+ bits[1, 31].to_i(2) * sign_multiplier
52
69
  end
53
70
  end
54
71
 
55
72
  class Float < Base
73
+ def skip_blank? = true
74
+
56
75
  # @param value [String]
57
76
  def type_cast(value)
77
+ return nil if value.empty?
78
+
58
79
  value.to_f
59
80
  end
60
81
  end
@@ -67,13 +88,20 @@ module DBF
67
88
  end
68
89
 
69
90
  class Boolean < Base
91
+ def skip_blank? = true
92
+ def blank_value = false
93
+
70
94
  # @param value [String]
71
95
  def type_cast(value)
72
- value.strip.match?(/^(y|t)$/i)
96
+ byte = value.getbyte(0)
97
+ byte == 89 || byte == 121 || byte == 84 || byte == 116 # Y y T t
73
98
  end
74
99
  end
75
100
 
76
101
  class Date < Base
102
+ def skip_blank? = true
103
+ def blank_value = false
104
+
77
105
  # @param value [String]
78
106
  def type_cast(value)
79
107
  value.match?(/\d{8}/) && ::Date.strptime(value, '%Y%m%d')
@@ -86,6 +114,8 @@ module DBF
86
114
  # @param value [String]
87
115
  def type_cast(value)
88
116
  days, msecs = value.unpack('l2')
117
+ return nil if days.nil? || days.zero?
118
+
89
119
  secs = (msecs / 1000).to_i
90
120
  ::DateTime.jd(days, (secs / 3600).to_i, (secs / 60).to_i % 60, secs % 60).to_time
91
121
  rescue StandardError
@@ -94,13 +124,16 @@ module DBF
94
124
  end
95
125
 
96
126
  class Memo < Base
127
+ def decode(raw, &)
128
+ memo_content = yield(raw)
129
+ memo_content ? type_cast(memo_content) : nil
130
+ end
131
+
97
132
  # @param value [String]
98
133
  def type_cast(value)
99
- if encoding && !value.nil?
100
- value.dup.force_encoding(@encoding).encode(Encoding.default_external, undef: :replace, invalid: :replace)
101
- else
102
- value
103
- end
134
+ return value unless encoding && value
135
+
136
+ Encoder.encode(value.dup, encoding)
104
137
  end
105
138
  end
106
139
 
@@ -112,10 +145,27 @@ module DBF
112
145
  end
113
146
 
114
147
  class String < Base
148
+ def initialize(column)
149
+ super
150
+ @target_encoding = Encoding.default_external
151
+ @needs_encode = encoding && encoding != @target_encoding
152
+ end
153
+
154
+ def skip_blank? = true
155
+ def blank_value = ''
156
+
115
157
  # @param value [String]
116
158
  def type_cast(value)
117
- value = value.strip
118
- @encoding ? value.force_encoding(@encoding).encode(Encoding.default_external, undef: :replace, invalid: :replace) : value
159
+ value.strip!
160
+ encoding ? encode(value) : value
161
+ end
162
+
163
+ private
164
+
165
+ def encode(value)
166
+ return value.force_encoding(encoding) unless @needs_encode
167
+
168
+ Encoder.encode(value, encoding, @target_encoding)
119
169
  end
120
170
  end
121
171
  end
@@ -26,7 +26,7 @@ module DBF
26
26
  @db = DBF::Table.new(@path)
27
27
  @tables = extract_dbc_data
28
28
  rescue Errno::ENOENT
29
- raise DBF::FileNotFoundError, "file not found: #{data}"
29
+ raise DBF::FileNotFoundError, "file not found: #{path}"
30
30
  end
31
31
 
32
32
  def table_names
@@ -38,9 +38,7 @@ module DBF
38
38
  # @param name [String]
39
39
  # @return [DBF::Table]
40
40
  def table(name)
41
- Table.new table_path(name) do |table|
42
- table.long_names = @tables[name]
43
- end
41
+ Table.new(table_path(name), long_names: @tables[name])
44
42
  end
45
43
 
46
44
  # Searches the database directory for the table's dbf file
@@ -58,7 +56,8 @@ module DBF
58
56
  end
59
57
 
60
58
  def method_missing(method, *args) # :nodoc:
61
- table_names.index(method.to_s) ? table(method.to_s) : super
59
+ name = method.to_s
60
+ table_names.index(name) ? table(name) : super
62
61
  end
63
62
 
64
63
  def respond_to_missing?(method, *)
@@ -72,45 +71,33 @@ module DBF
72
71
  # are in the same order as in the linked tables but only the long name
73
72
  # is provided.
74
73
  def extract_dbc_data # :nodoc:
75
- data = {}
76
- @db.each do |record|
74
+ build_table_data.values.to_h { |entry| entry.values_at(:name, :fields) }
75
+ end
76
+
77
+ def build_table_data # :nodoc:
78
+ @db.each_with_object({}) do |record, hash|
77
79
  next unless record
78
80
 
81
+ name = record.objectname
79
82
  case record.objecttype
80
- when 'Table'
81
- # This is a related table
82
- process_table record, data
83
- when 'Field'
84
- # This is a related field. The parentid points to the table object.
85
- # Create using the parentid if the parentid is still unknown.
86
- process_field record, data
83
+ when 'Table' then hash[record.objectid] = table_field_hash(name)
84
+ when 'Field' then (hash[record.parentid] ||= table_field_hash('UNKNOWN'))[:fields] << name
87
85
  end
88
86
  end
89
-
90
- data.values.to_h { |v| [v[:name], v[:fields]] }
91
- end
92
-
93
- def process_table(record, data)
94
- id = record.objectid
95
- name = record.objectname
96
- data[id] = table_field_hash(name)
97
- end
98
-
99
- def process_field(record, data)
100
- id = record.parentid
101
- name = 'UNKNOWN'
102
- field = record.objectname
103
- data[id] ||= table_field_hash(name)
104
- data[id][:fields] << field
105
87
  end
106
88
 
107
89
  def table_field_hash(name)
108
- {name: name, fields: []}
90
+ {name:, fields: []}
109
91
  end
110
92
  end
111
93
 
112
94
  class Table < DBF::Table
113
- attr_accessor :long_names
95
+ attr_reader :long_names
96
+
97
+ def initialize(path, long_names:)
98
+ @long_names = long_names
99
+ super(path)
100
+ end
114
101
 
115
102
  def build_columns # :nodoc:
116
103
  columns = super