dbf 5.4.0 → 5.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f296a6fc75892231410af21b2ee94490666f740f425f671078b5c28ded1e2977
4
- data.tar.gz: eed4dfd84582cdf9c8e125ca2ddb1e882ebe75147321469db26166f7777a1ea1
3
+ metadata.gz: ff36c9c3d6b7a558cd5bf0f703f64f0cb7b22cbf6b4c52b843c855328a712a0a
4
+ data.tar.gz: ac0f725043e7dc9102a32381f6041e54ebd852a67a264778ae58b2a59430b01e
5
5
  SHA512:
6
- metadata.gz: 578f0f6cc7c88a205c38fd2ef93af2d8e5c924483018af4b96fb349dd12f40386c13547dfc67cd6fbdd47e191a0d04ef46bf0e1c38f9baec6057197661cd6945
7
- data.tar.gz: 6960aba5a5b253cc6041c0822a504714ab5a511fee4ea1ff84f43cde10c8cf7db4d74d46f84e670d49f3a4d7110525a3196ea2f76cbcc37174a657e56a755fae
6
+ metadata.gz: 269035188f9741d80575efca78ae0bda153c4f27c79dbe3f3e6221fc714be22eb2fb9ec39899dcfc9d489f76d60bfaf1f892d6eeaaeea80fa406f5f401e909d7
7
+ data.tar.gz: 6203ede51060bad9d193b15bb98d814aa870d99dd29d6eb8ddd4cb2b5e04f078f522fb7f3051befcbebe11be86a13307da38f9981bbcb704c5307ebdb16dd6a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.5.0
4
+
5
+ ### Added
6
+
7
+ - `DBF::Table.new` accepts any IO-like object responding to `#read` and `#seek` (`File`, `Tempfile`, …) in addition to a path or `StringIO`, matching what the README already documented; IO inputs are switched to binary mode, and memo auto-discovery works from the IO's path when it has one
8
+ - `DBF::Error` base class: all library errors (`FileNotFoundError`, `NoColumnsDefined`, `Column::LengthError`, `Column::InvalidNameError`) now inherit from it, so callers can `rescue DBF::Error` to catch anything the library raises
9
+ - `Table#filename` now works for any IO input with a path, not only `File`
10
+ - `DBF::Table.open`: same arguments as `.new`, but the block form yields the table, closes it when the block returns, and returns the block's value — the same contract as `File.open`
11
+ - CI: lint job running RuboCop and Reek on every push and pull request
12
+ - CI: test on Windows and macOS (Ruby 3.4) in addition to Linux, since much real-world DBF data originates on Windows/FoxPro systems
13
+ - CI: observe-only (non-blocking) test jobs for JRuby, TruffleRuby, and Ruby head
14
+ - CI: enforce a 99% minimum test coverage floor via SimpleCov
15
+ - Docs site (dbf.infused.org): YARD docs are now built and deployed by CI instead of being committed to the repository; the Pages workflow's deprecated v2/v3 actions (whose artifact backend was shut down in early 2025) are updated to current versions, repairing the broken deploy
16
+ - Dependabot updates for GitHub Actions and gems (weekly), a security policy (SECURITY.md) with private vulnerability reporting, and contributor documentation (CONTRIBUTING.md)
17
+ - Automated releases: pushing a `vX.Y.Z` tag now builds and publishes the gem to RubyGems.org from CI via Trusted Publishing (OIDC) — no long-lived API key. `bundler/gem_tasks` is loaded in the Rakefile, providing `rake build` / `rake release`. Git tags resume at v5.4.0; versions 4.0.0–5.3.0 were released without tags
18
+
19
+ - Ractor safety: all library constants are verified Ractor-shareable and reading/exporting tables inside non-main Ractors is covered by specs
20
+ - CLI: `-j` exports records as a JSON array and `-J` as JSON Lines (one record per line, streamed in constant memory); deleted records are skipped, control bytes are escaped by JSON itself, and binary cells are scrubbed instead of raising
21
+
22
+ ### Fixed
23
+
24
+ - `Table#to_csv(path)` closes the file it opens, so the CSV is fully flushed to disk when the method returns (previously the data could stay buffered until garbage collection)
25
+ - Visual FoxPro: a `.dbc` container supplying fewer long names than the table has columns no longer crashes column building; missing long names fall back to the table's own column names. Column rebuilding is also no longer O(n²)
26
+ - `Table#schema` validates the format against the documented list up front; a `NoMethodError` raised inside a valid schema generator is no longer misreported as "not a valid schema"
27
+ - FoxPro memo files shorter than their 512-byte header no longer crash memo reads (they return nil like other truncations)
28
+ - RuboCop config: restore the default `vendor/**/*` exclusion (overriding `Exclude` replaces the defaults), fixing a CI lint crash where RuboCop descended into cached vendored gems and tried to load plugins from their configs
29
+ - CLI: close the table when finished, so the DBF file can be deleted or replaced immediately afterwards on Windows (an open handle blocks deletion there)
30
+
31
+ ### Removed
32
+
33
+ - CodeClimate config and README badges: the service is sunset, and coverage is now enforced directly in CI
34
+
35
+ ### Changed
36
+
37
+ - Streaming reads: `Table#each` now reads records in 4 MB chunks of whole records instead of loading the entire record section into memory, so enumerating multi-gigabyte files (for example shapefile `.dbf` sidecars) uses bounded memory. On a 190 MB file, peak process memory drops from ~222 MB to ~35 MB with identical results. No API change; all malformed-file bounds are preserved
38
+ - Internal: `DBF::RecordContext` is now an immutable `Data` class instead of a `Struct`
39
+ - Internal: remove a redundant `Memo::Foxpro#initialize` and the duplicate `Header::HEADER_SIZE` constant (now sourced from `VersionConfig`)
40
+ - README: replace the stack of version-support notes with a compatibility table, point API docs at dbf.infused.org, and refer to LICENSE instead of inlining the full MIT text; copyright years updated through 2026
41
+ - Gemspec: add `homepage_uri`, `bug_tracker_uri`, `documentation_uri`, and `funding_uri` metadata (shown on the rubygems.org sidebar), and constrain the `csv` runtime dependency to `~> 3.3`
42
+ - `DBF::Column::NameError` is renamed to `DBF::Column::InvalidNameError` (it shadowed Ruby's `::NameError` without being one); the old constant remains as a deprecated alias
43
+ - Rescues in date/datetime decoding and FoxPro memo reads are narrowed to the specific expected errors, so genuine programmer errors surface instead of silently returning nil
44
+ - Declare `reek` and `simplecov` as direct development dependencies instead of relying on rubycritic's transitive dependencies
45
+ - Restrict `debug` and `ruby-lsp` to MRI so `bundle install` succeeds on JRuby/TruffleRuby
46
+ - Test matrix no longer fails fast, and a Ruby head regression no longer fails the build
47
+ - Update pinned `ruby/setup-ruby` action to v1.321.0
48
+
3
49
  ## 5.4.0
4
50
 
5
51
  - CLI: replace terminal control bytes in file-derived output so a crafted DBF cannot emit escape sequences to an interactive terminal; CSV and schema output are only filtered when writing to a terminal, so redirected exports are unchanged
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2006-2024 Keith Morrison <keithm@infused.org>
1
+ Copyright (c) 2006-2026 Keith Morrison <keithm@infused.org>
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -2,36 +2,37 @@
2
2
 
3
3
  [![Version](https://img.shields.io/gem/v/dbf.svg?style=flat)](https://rubygems.org/gems/dbf)
4
4
  [![Build Status](https://github.com/infused/dbf/actions/workflows/build.yml/badge.svg)](https://github.com/infused/dbf/actions/workflows/build.yml)
5
- [![Code Quality](https://img.shields.io/codeclimate/maintainability/infused/dbf.svg?style=flat)](https://codeclimate.com/github/infused/dbf)
6
- [![Code Coverage](https://img.shields.io/codeclimate/c/infused/dbf.svg?style=flat)](https://codeclimate.com/github/infused/dbf)
7
5
  [![Total Downloads](https://img.shields.io/gem/dt/dbf.svg)](https://rubygems.org/gems/dbf/)
8
6
  [![License](https://img.shields.io/github/license/infused/dbf.svg)](https://github.com/infused/dbf)
9
7
 
10
8
  DBF is a small, fast Ruby library for reading dBase, xBase, Clipper, and FoxPro database files.
11
9
 
12
10
  * Project page: <https://github.com/infused/dbf>
13
- * API Documentation: <https://rdoc.info/gems/dbf>
11
+ * API Documentation: <https://dbf.infused.org>
14
12
  * Report bugs: <https://github.com/infused/dbf/issues>
15
13
  * Questions: Email <mailto:keithm@infused.org> and put DBF somewhere in the
16
14
  subject line
17
15
  * Change log: <https://github.com/infused/dbf/blob/main/CHANGELOG.md>
18
16
 
19
- NOTE: Beginning with version 5.2 we have dropped support for Ruby 3.2 and earlier.
20
-
21
- NOTE: Beginning with version 4.3 we have dropped support for Ruby 3.0 and earlier.
22
-
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,
24
- please use 3.0.x (<https://github.com/infused/dbf/tree/3_stable>)
25
-
26
- NOTE: Beginning with version 3 we have dropped support for Ruby 1.8 and 1.9. If you need support for older Rubies,
27
- please use 2.0.x (<https://github.com/infused/dbf/tree/2_stable>)
28
-
29
17
  ## Compatibility
30
18
 
31
19
  DBF is tested to work with the following versions of Ruby:
32
20
 
33
21
  * Ruby 3.3.x, 3.4.x, 4.0.x
34
22
 
23
+ Older Rubies are supported by older DBF release lines:
24
+
25
+ | DBF version | Ruby support |
26
+ | ----------- | ------------ |
27
+ | 5.2+ | 3.3+ |
28
+ | 4.3 – 5.1 | 3.1+ |
29
+ | 4.0 – 4.2 | 2.4+ |
30
+ | 3.x ([3_stable](https://github.com/infused/dbf/tree/3_stable)) | 2.0 – 2.3 |
31
+ | 2.x ([2_stable](https://github.com/infused/dbf/tree/2_stable)) | 1.8 – 1.9 |
32
+
33
+ DBF is Ractor-friendly: all library constants are deeply frozen, so tables
34
+ can be opened, enumerated, and exported inside non-main Ractors.
35
+
35
36
  ## Installation
36
37
 
37
38
  Install the gem manually:
@@ -58,16 +59,35 @@ widgets = DBF::Table.new("widgets.dbf")
58
59
  Open a DBF file using an IO object:
59
60
 
60
61
  ```ruby
61
- data = File.open('widgets.dbf')
62
+ data = File.open('widgets.dbf', 'rb')
62
63
  widgets = DBF::Table.new(data)
63
64
  ```
64
65
 
66
+ All errors raised by the library inherit from DBF::Error, so you can rescue
67
+ the library as a unit:
68
+
69
+ ```ruby
70
+ begin
71
+ widgets = DBF::Table.new("widgets.dbf")
72
+ rescue DBF::Error => e
73
+ puts "Unable to read DBF file: #{e.message}"
74
+ end
75
+ ```
76
+
65
77
  Open a DBF by passing in raw data (wrap the raw data with StringIO):
66
78
 
67
79
  ```ruby
68
80
  widgets = DBF::Table.new(StringIO.new('raw binary data'))
69
81
  ```
70
82
 
83
+ Use the block form to close the table automatically, like File.open:
84
+
85
+ ```ruby
86
+ DBF::Table.open('widgets.dbf') do |table|
87
+ table.each { |record| puts record.name }
88
+ end
89
+ ```
90
+
71
91
  Enumerate all records
72
92
 
73
93
  ```ruby
@@ -287,13 +307,15 @@ table.name = 'my_table_name'
287
307
  A small command-line utility called dbf is installed with the gem.
288
308
 
289
309
  $ dbf -h
290
- usage: dbf [-h|-s|-a] filename
310
+ usage: dbf [-h|-s|-a|-c|-r|-j|-J] filename
291
311
  -h = print this message
292
- -v = print the version number
312
+ -v = print the DBF gem version
293
313
  -s = print summary information
294
314
  -a = create an ActiveRecord::Schema
295
- -r = create a Sequel Migration
315
+ -r = create a Sequel migration
296
316
  -c = export as CSV
317
+ -j = export as a JSON array
318
+ -J = export as JSON Lines (one record per line)
297
319
 
298
320
  Create an executable ActiveRecord schema:
299
321
 
@@ -307,6 +329,15 @@ Dump all records to a CSV file:
307
329
 
308
330
  dbf -c books.dbf > books.csv
309
331
 
332
+ Dump all records as JSON or JSON Lines:
333
+
334
+ dbf -j books.dbf > books.json
335
+ dbf -J books.dbf > books.jsonl
336
+
337
+ JSON Lines output is streamed record by record, so it works well for very
338
+ large files and pipelines (for example `dbf -J books.dbf | jq` or importing
339
+ into DuckDB).
340
+
310
341
  ## Reading a Visual Foxpro database (v8, v9)
311
342
 
312
343
  A special Database::Foxpro class is available to read Visual Foxpro container
@@ -374,25 +405,6 @@ Data type descriptions
374
405
 
375
406
  ## License
376
407
 
377
- Copyright (c) 2006-2024 Keith Morrison <<keithm@infused.org>>
378
-
379
- Permission is hereby granted, free of charge, to any person
380
- obtaining a copy of this software and associated documentation
381
- files (the "Software"), to deal in the Software without
382
- restriction, including without limitation the rights to use,
383
- copy, modify, merge, publish, distribute, sublicense, and/or sell
384
- copies of the Software, and to permit persons to whom the
385
- Software is furnished to do so, subject to the following
386
- conditions:
387
-
388
- The above copyright notice and this permission notice shall be
389
- included in all copies or substantial portions of the Software.
390
-
391
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
392
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
393
- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
394
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
395
- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
396
- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
397
- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
398
- OTHER DEALINGS IN THE SOFTWARE.
408
+ Copyright (c) 2006-2026 Keith Morrison <<keithm@infused.org>>
409
+
410
+ Released under the MIT License. See [LICENSE](LICENSE) for the full text.
data/dbf.gemspec CHANGED
@@ -15,7 +15,11 @@ Gem::Specification.new do |s|
15
15
  s.require_paths = ['lib']
16
16
  s.required_ruby_version = '>= 3.3.0'
17
17
  s.metadata['rubygems_mfa_required'] = 'true'
18
+ s.metadata['homepage_uri'] = 'https://github.com/infused/dbf'
18
19
  s.metadata['source_code_uri'] = 'https://github.com/infused/dbf'
19
20
  s.metadata['changelog_uri'] = 'https://github.com/infused/dbf/blob/main/CHANGELOG.md'
20
- s.add_dependency 'csv'
21
+ s.metadata['bug_tracker_uri'] = 'https://github.com/infused/dbf/issues'
22
+ s.metadata['documentation_uri'] = 'https://dbf.infused.org'
23
+ s.metadata['funding_uri'] = 'https://github.com/sponsors/infused'
24
+ s.add_dependency 'csv', '~> 3.3'
21
25
  end
data/lib/dbf/cli.rb CHANGED
@@ -5,13 +5,15 @@ require 'optparse'
5
5
  module DBF
6
6
  class CLI
7
7
  USAGE = <<~HELP
8
- usage: dbf [-h|-s|-a|-c|-r] filename
8
+ usage: dbf [-h|-s|-a|-c|-r|-j|-J] filename
9
9
  -h = print this message
10
10
  -v = print the DBF gem version
11
11
  -s = print summary information
12
12
  -a = create an ActiveRecord::Schema
13
13
  -r = create a Sequel migration
14
14
  -c = export as CSV
15
+ -j = export as a JSON array
16
+ -J = export as JSON Lines (one record per line)
15
17
  HELP
16
18
 
17
19
  # Bytes a terminal interprets as control or escape sequences. A crafted
@@ -68,8 +70,17 @@ module DBF
68
70
  @stderr = stderr
69
71
  end
70
72
 
73
+ ACTIONS = {
74
+ 'a' => :print_ar_schema,
75
+ 'r' => :print_sequel_schema,
76
+ 's' => :print_summary,
77
+ 'c' => :print_csv,
78
+ 'j' => :print_json,
79
+ 'J' => :print_jsonl
80
+ }.freeze
81
+
71
82
  def run
72
- params = OptionParser.new.getopts(@argv, 'h', 's', 'a', 'c', 'r', 'v')
83
+ params = OptionParser.new.getopts(@argv, 'hsacrvjJ')
73
84
 
74
85
  if params['v']
75
86
  print_version
@@ -79,13 +90,8 @@ module DBF
79
90
  filename = @argv.shift
80
91
  return missing_filename unless filename
81
92
 
82
- action = %w[a r s c].find { |flag| params[flag] }
83
- case action
84
- when 'a' then print_ar_schema(filename)
85
- when 'r' then print_sequel_schema(filename)
86
- when 's' then print_summary(filename)
87
- when 'c' then print_csv(filename)
88
- end
93
+ action = ACTIONS.find { |flag, _method| params[flag] }&.last
94
+ send(action, filename) if action
89
95
  end
90
96
  0
91
97
  rescue DBF::FileNotFoundError => e
@@ -108,16 +114,23 @@ module DBF
108
114
  1
109
115
  end
110
116
 
117
+ # Always close the table when done: an open handle prevents deleting or
118
+ # replacing the file on Windows.
119
+ def with_table(filename, &) = DBF::Table.open(filename, &)
120
+
111
121
  def print_ar_schema(filename)
112
- @stdout.puts terminal_safe(DBF::Table.new(filename).schema(:activerecord))
122
+ with_table(filename) { |table| @stdout.puts terminal_safe(table.schema(:activerecord)) }
113
123
  end
114
124
 
115
125
  def print_sequel_schema(filename)
116
- @stdout.puts terminal_safe(DBF::Table.new(filename).schema(:sequel))
126
+ with_table(filename) { |table| @stdout.puts terminal_safe(table.schema(:sequel)) }
117
127
  end
118
128
 
119
129
  def print_summary(filename)
120
- table = DBF::Table.new(filename)
130
+ with_table(filename) { |table| write_summary(filename, table) }
131
+ end
132
+
133
+ def write_summary(filename, table)
121
134
  @stdout.puts
122
135
  @stdout.puts "Database: #{filename}"
123
136
  @stdout.puts "Type: (#{table.version}) #{table.version_description}"
@@ -136,7 +149,54 @@ module DBF
136
149
  end
137
150
 
138
151
  def print_csv(filename)
139
- DBF::Table.new(filename).to_csv(interactive? ? TerminalFilter.new(@stdout) : @stdout)
152
+ with_table(filename) { |table| table.to_csv(interactive? ? TerminalFilter.new(@stdout) : @stdout) }
153
+ end
154
+
155
+ # Streams a JSON array without materializing all records in memory.
156
+ # JSON string escaping makes the output terminal-safe by construction:
157
+ # control bytes are emitted as \uXXXX escapes.
158
+ def print_json(filename)
159
+ with_table(filename) { |table| write_json(table) }
160
+ end
161
+
162
+ def write_json(table)
163
+ first = true
164
+ @stdout.write('[')
165
+ each_present_record(table) do |record|
166
+ @stdout.write(first ? "\n" : ",\n")
167
+ @stdout.write(json_record(record))
168
+ first = false
169
+ end
170
+ @stdout.write("\n]\n")
171
+ end
172
+
173
+ # One JSON object per line (JSON Lines). Combined with chunked record
174
+ # reading this exports arbitrarily large files in constant memory.
175
+ def print_jsonl(filename)
176
+ with_table(filename) { |table| write_jsonl(table) }
177
+ end
178
+
179
+ def write_jsonl(table)
180
+ each_present_record(table) { |record| @stdout.write("#{json_record(record)}\n") }
181
+ end
182
+
183
+ # Deleted records have no attributes, so JSON export skips them.
184
+ def each_present_record(table, &)
185
+ table.each { |record| yield record if record }
186
+ end
187
+
188
+ def json_record(record)
189
+ JSON.generate(record.attributes.to_h { |key, value| [json_safe(key), json_safe(value)] })
190
+ end
191
+
192
+ # JSON.generate raises on binary or invalidly encoded strings; represent
193
+ # their bytes instead of raising, mirroring the CSV export behavior.
194
+ def json_safe(value)
195
+ return value unless value.is_a?(::String)
196
+
197
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
198
+ rescue Encoding::ConverterNotFoundError
199
+ value.dup.force_encoding(Encoding::UTF_8).scrub('?')
140
200
  end
141
201
 
142
202
  # Exported data is only filtered when it is going to a terminal, so
data/lib/dbf/column.rb CHANGED
@@ -2,12 +2,17 @@
2
2
 
3
3
  module DBF
4
4
  class Column
5
- class LengthError < StandardError
5
+ class LengthError < DBF::Error
6
6
  end
7
7
 
8
- class NameError < StandardError
8
+ class InvalidNameError < DBF::Error
9
9
  end
10
10
 
11
+ # Deprecated alias, kept for backward compatibility. It shadowed
12
+ # ::NameError (without being one), so it was renamed.
13
+ NameError = InvalidNameError
14
+ deprecate_constant :NameError
15
+
11
16
  attr_reader :name, :type, :length, :decimal
12
17
 
13
18
  # rubocop:disable Style/MutableConstant
@@ -105,7 +110,7 @@ module DBF
105
110
  end
106
111
 
107
112
  def validate_name # :nodoc:
108
- raise NameError, 'column name cannot be empty' if @name.empty?
113
+ raise InvalidNameError, 'column name cannot be empty' if @name.empty?
109
114
  end
110
115
  end
111
116
  end
@@ -112,7 +112,7 @@ module DBF
112
112
  # @param value [String]
113
113
  def type_cast(value)
114
114
  value.match?(/\d{8}/) && ::Date.strptime(value, '%Y%m%d')
115
- rescue StandardError
115
+ rescue ArgumentError, RangeError, TypeError
116
116
  nil
117
117
  end
118
118
  end
@@ -125,7 +125,7 @@ module DBF
125
125
 
126
126
  secs = (msecs / 1000).to_i
127
127
  ::DateTime.jd(days, (secs / 3600).to_i, (secs / 60).to_i % 60, secs % 60).to_time
128
- rescue StandardError
128
+ rescue ArgumentError, RangeError, TypeError
129
129
  nil
130
130
  end
131
131
  end
@@ -114,12 +114,10 @@ module DBF
114
114
  end
115
115
 
116
116
  def build_columns # :nodoc:
117
- columns = super
118
-
119
117
  # modify the column definitions to use the long names as the
120
118
  # columnname property is readonly, recreate the column definitions
121
- columns.map do |column|
122
- long_name = long_names[columns.index(column)]
119
+ super.each_with_index.map do |column, index|
120
+ long_name = long_names[index] || column.name
123
121
  Column.new(self, long_name, column.type, column.length, column.decimal)
124
122
  end
125
123
  end
data/lib/dbf/errors.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBF
4
+ # Base class for all errors raised by the DBF library, so callers can
5
+ # rescue DBF::Error to catch anything the library raises.
6
+ class Error < StandardError
7
+ end
8
+
9
+ class FileNotFoundError < Error
10
+ end
11
+
12
+ class NoColumnsDefined < Error
13
+ end
14
+ end
@@ -11,7 +11,12 @@ module DBF
11
11
  when String
12
12
  File.open(data, 'rb')
13
13
  else
14
- raise ArgumentError, 'data must be a file path or StringIO object'
14
+ raise ArgumentError, 'data must be a file path or an IO-like object responding to #read and #seek' unless data.respond_to?(:read) && data.respond_to?(:seek)
15
+
16
+ # DBF is a binary format; a File opened in text mode would corrupt
17
+ # reads on Windows.
18
+ data.binmode if data.respond_to?(:binmode)
19
+ data
15
20
  end
16
21
  rescue Errno::ENOENT
17
22
  raise DBF::FileNotFoundError, "file not found: #{data}"
@@ -19,17 +24,23 @@ module DBF
19
24
 
20
25
  def open_memo(data, memo, memo_class, version)
21
26
  if memo
22
- meth = memo.is_a?(StringIO) ? :new : :open
27
+ meth = memo.is_a?(String) ? :open : :new
23
28
  memo_class.send(meth, memo, version)
24
- elsif !data.is_a?(StringIO)
25
- path = Dir.glob(memo_search_path(data)).first
26
- path && memo_class.open(path, version)
29
+ elsif (path = data_path(data))
30
+ found = Dir.glob(memo_search_path(path)).first
31
+ found && memo_class.open(found, version)
27
32
  end
28
33
  end
29
34
 
30
- def memo_search_path(io)
31
- dirname = File.dirname(io)
32
- basename = File.basename(io, '.*')
35
+ def data_path(data)
36
+ return data if data.is_a?(String)
37
+
38
+ data.path if data.respond_to?(:path)
39
+ end
40
+
41
+ def memo_search_path(path)
42
+ dirname = File.dirname(path)
43
+ basename = File.basename(path, '.*')
33
44
  "#{dirname}/#{basename}*.{fpt,FPT,dbt,DBT}"
34
45
  end
35
46
  end
data/lib/dbf/header.rb CHANGED
@@ -2,14 +2,12 @@
2
2
 
3
3
  module DBF
4
4
  class Header
5
- HEADER_SIZE = 32
6
-
7
5
  attr_reader :version, :record_count, :header_length, :record_length, :encoding_key, :encoding
8
6
 
9
7
  def initialize(data)
10
8
  # Pad a nil or truncated header read so unpacking a short file yields
11
9
  # empty values instead of raising.
12
- data = data.to_s.b.ljust(HEADER_SIZE, "\x00")
10
+ data = data.to_s.b.ljust(VersionConfig::DBASE3_HEADER_SIZE, "\x00")
13
11
  @version = data.unpack1('H2')
14
12
  @encoding_key = nil
15
13
  @encoding = nil
@@ -5,18 +5,17 @@ module DBF
5
5
  class Foxpro < Base
6
6
  FPT_HEADER_SIZE = 512
7
7
 
8
- def initialize(data, version)
9
- @data = data
10
- super
11
- end
12
-
13
8
  def build_memo(start_block) # :nodoc:
14
- @data.seek offset(start_block)
15
- memo_type, memo_size, memo_string = @data.read(block_size).unpack('NNa*')
16
- return nil unless memo_type == 1 && memo_size > 0
9
+ data.seek offset(start_block)
10
+ block = data.read(block_size)
11
+ return nil unless block
12
+
13
+ # memo_size is nil when the block header is truncated (< 8 bytes)
14
+ memo_type, memo_size, memo_string = block.unpack('NNa*')
15
+ return nil unless memo_type == 1 && memo_size.to_i.positive?
17
16
 
18
17
  read_memo_content(memo_string, memo_size)
19
- rescue StandardError
18
+ rescue IOError, SystemCallError, RangeError
20
19
  nil
21
20
  end
22
21
 
@@ -28,16 +27,18 @@ module DBF
28
27
  # Bound the read by the bytes remaining so a crafted 32-bit memo_size
29
28
  # cannot force a ~4 GiB allocation from a small memo file.
30
29
  length = content_size(memo_size)
31
- remaining = @data.size - @data.pos
30
+ remaining = data.size - data.pos
32
31
  length = remaining if length > remaining
33
- memo_string << @data.read(length) if length.positive?
32
+ memo_string << data.read(length) if length.positive?
34
33
  memo_string
35
34
  end
36
35
 
37
36
  def block_size # :nodoc:
38
37
  @block_size ||= begin
39
- @data.rewind
40
- @data.read(FPT_HEADER_SIZE).unpack1('x6n') || 0
38
+ data.rewind
39
+ header = data.read(FPT_HEADER_SIZE)
40
+ # A header shorter than 8 bytes cannot contain the block size field
41
+ header && header.bytesize >= 8 ? header.unpack1('x6n') : 0
41
42
  end
42
43
  end
43
44
  end
data/lib/dbf/record.rb CHANGED
@@ -20,9 +20,7 @@ module DBF
20
20
  # @param [DBF::Record] other
21
21
  # @return [Boolean]
22
22
  def ==(other)
23
- attributes == other.attributes
24
- rescue NoMethodError
25
- false
23
+ other.respond_to?(:attributes) && attributes == other.attributes
26
24
  end
27
25
 
28
26
  # Reads attributes by column name
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DBF
4
- RecordContext = Struct.new(:columns, :version, :memo, :column_offsets, keyword_init: true)
4
+ # Immutable per-table state shared by every record during decoding
5
+ RecordContext = Data.define(:columns, :version, :memo, :column_offsets)
5
6
  end
@@ -2,46 +2,75 @@
2
2
 
3
3
  module DBF
4
4
  class RecordIterator
5
- def initialize(data, context, header_length, record_length, record_count)
5
+ # Records are read in chunks of whole records totalling roughly this many
6
+ # bytes, so enumerating a multi-gigabyte file (for example a shapefile
7
+ # sidecar) needs only chunk-sized memory instead of the entire record
8
+ # section at once.
9
+ CHUNK_SIZE = 4 * 1024 * 1024
10
+
11
+ def initialize(data, context, header_length, record_length, record_count, chunk_size: CHUNK_SIZE)
6
12
  @data = data
7
13
  @context = context
8
14
  @header_length = header_length
9
15
  @record_length = record_length
10
16
  @record_count = record_count
17
+ @chunk_size = chunk_size
18
+ end
19
+
20
+ def each(&)
21
+ return enum_for(:each) unless block_given?
22
+
23
+ # A record_length of 0 from a crafted header cannot drive an unbounded
24
+ # loop: capacity is 0 and enumeration ends immediately.
25
+ remaining = record_capacity
26
+ @data.seek(@header_length)
27
+
28
+ while remaining.positive?
29
+ wanted = [per_chunk, remaining].min
30
+ buffer = @data.read(wanted * @record_length)
31
+ break unless buffer
32
+
33
+ whole_records = buffer.bytesize / @record_length
34
+ break if whole_records.zero?
35
+
36
+ yield_chunk(buffer, whole_records, &)
37
+ remaining -= whole_records
38
+
39
+ # A short read means the file ended earlier than the header promised
40
+ break if whole_records < wanted
41
+ end
11
42
  end
12
43
 
13
- def each
14
- buf = read_buffer
15
- return unless buf
44
+ private
16
45
 
17
- # Bound the iteration by the bytes actually read so a crafted
18
- # record_count (or record_length == 0) cannot drive an unbounded loop.
19
- max_records = @record_length > 0 ? buf.bytesize / @record_length : 0
20
- count = @record_count < max_records ? @record_count : max_records
46
+ # Whole records per read; at least one so a record larger than the chunk
47
+ # size still makes progress. Only called when record_length is positive.
48
+ def per_chunk
49
+ @per_chunk ||= [@chunk_size / @record_length, 1].max
50
+ end
21
51
 
52
+ def yield_chunk(buffer, count)
22
53
  pos = 0
23
54
  count.times do
24
- if buf.getbyte(pos) == 0x2A
55
+ if buffer.getbyte(pos) == 0x2A
25
56
  yield nil
26
57
  else
27
- yield Record.new(buf, @context, pos + 1)
58
+ yield Record.new(buffer, @context, pos + 1)
28
59
  end
29
60
  pos += @record_length
30
61
  end
31
62
  end
32
63
 
33
- private
34
-
35
- def read_buffer
36
- @data.seek(@header_length)
64
+ # Bound enumeration by the bytes actually available so a crafted header
65
+ # (huge record_length * record_count) cannot force reads past the real
66
+ # file size, while record_count still caps a file with trailing garbage.
67
+ def record_capacity
68
+ return 0 unless @record_length.positive?
37
69
 
38
- # Bound the allocation by the bytes actually available so a crafted
39
- # header (huge record_length * record_count) cannot force a giant read
40
- # from a tiny file.
41
- requested = @record_length * @record_count
42
70
  available = @data.size - @header_length
43
- available = 0 if available.negative?
44
- @data.read(requested < available ? requested : available)
71
+ return 0 if available.negative?
72
+
73
+ [@record_count, available / @record_length].min
45
74
  end
46
75
  end
47
76
  end
data/lib/dbf/schema.rb CHANGED
@@ -43,10 +43,9 @@ module DBF
43
43
  # @param table_only [Boolean]
44
44
  # @return [String]
45
45
  def schema(format = :activerecord, table_only: false)
46
- schema_method_name = schema_name(format)
47
- send(schema_method_name, table_only: table_only)
48
- rescue NameError
49
- raise ArgumentError, ":#{format} is not a valid schema. Valid schemas are: #{FORMATS.join(', ')}."
46
+ raise ArgumentError, ":#{format} is not a valid schema. Valid schemas are: #{FORMATS.join(', ')}." unless FORMATS.include?(format.to_s.to_sym)
47
+
48
+ send(schema_name(format), table_only: table_only)
50
49
  end
51
50
 
52
51
  def schema_name(format) # :nodoc:
data/lib/dbf/table.rb CHANGED
@@ -1,12 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DBF
4
- class FileNotFoundError < StandardError
5
- end
6
-
7
- class NoColumnsDefined < StandardError
8
- end
9
-
10
4
  # Leading bytes that make a spreadsheet treat a CSV cell as a formula:
11
5
  # "=", "+", "-", "@", tab, and carriage return.
12
6
  CSV_FORMULA_TRIGGERS = [0x3D, 0x2B, 0x2D, 0x40, 0x09, 0x0D].freeze
@@ -37,6 +31,9 @@ module DBF
37
31
  # # working with a dbf in memory
38
32
  # table = DBF::Table.new StringIO.new(dbf_data)
39
33
  #
34
+ # # working with an open IO object
35
+ # table = DBF::Table.new File.open('data.dbf', 'rb')
36
+ #
40
37
  # # working with a dbf and memo in memory
41
38
  # table = DBF::Table.new StringIO.new(dbf_data), StringIO.new(memo_data)
42
39
  #
@@ -44,8 +41,28 @@ module DBF
44
41
  # table = DBF::Table.new 'data.dbf', nil, 'cp437'
45
42
  # table = DBF::Table.new 'data.dbf', 'memo.dbt', Encoding::US_ASCII
46
43
  #
47
- # @param data [String, StringIO] data Path to the dbf file or a StringIO object
48
- # @param memo [optional String, StringIO] memo Path to the memo file or a StringIO object
44
+ # Opens a table like .new, but when given a block, yields the table,
45
+ # closes it when the block returns, and returns the block's value
46
+ # the same contract as File.open.
47
+ #
48
+ # DBF::Table.open('data.dbf') do |table|
49
+ # table.each { |record| ... }
50
+ # end
51
+ #
52
+ # Takes the same arguments as .new. Without a block, equivalent to .new.
53
+ def self.open(data, memo = nil, encoding = nil, name: nil)
54
+ table = new(data, memo, encoding, name: name)
55
+ return table unless block_given?
56
+
57
+ begin
58
+ yield table
59
+ ensure
60
+ table.close
61
+ end
62
+ end
63
+
64
+ # @param data [String, StringIO, IO] data Path to the dbf file or an IO-like object
65
+ # @param memo [optional String, StringIO, IO] memo Path to the memo file or an IO-like object
49
66
  # @param encoding [optional String, Encoding] encoding Name of the encoding or an Encoding object
50
67
  def initialize(data, memo = nil, encoding = nil, name: nil)
51
68
  @data = FileHandler.open_data(data)
@@ -110,7 +127,7 @@ module DBF
110
127
 
111
128
  # @return [String]
112
129
  def filename
113
- File.basename(@data.path) if @data.is_a?(File)
130
+ File.basename(@data.path) if @data.respond_to?(:path)
114
131
  end
115
132
 
116
133
  # @return [TrueClass, FalseClass]
@@ -150,14 +167,11 @@ module DBF
150
167
  #
151
168
  # @param [optional String, IO] path_or_io String path, IO-like object, or nil for STDOUT
152
169
  def to_csv(path_or_io = nil)
153
- io = case path_or_io
154
- when nil then $stdout
155
- when String then File.open(path_or_io, 'w')
156
- else path_or_io
170
+ if path_or_io.is_a?(String)
171
+ File.open(path_or_io, 'w') { |file| write_csv(file) }
172
+ else
173
+ write_csv(path_or_io || $stdout)
157
174
  end
158
- csv = CSV.new(io, force_quotes: true)
159
- csv << column_names.map { |name| csv_safe_value(name) }
160
- each { |record| csv << record.to_a.map { |value| csv_safe_value(value) } }
161
175
  end
162
176
 
163
177
  # Human readable version description
@@ -184,6 +198,12 @@ module DBF
184
198
 
185
199
  private
186
200
 
201
+ def write_csv(io) # :nodoc:
202
+ csv = CSV.new(io, force_quotes: true)
203
+ csv << column_names.map { |name| csv_safe_value(name) }
204
+ each { |record| csv << record.to_a.map { |value| csv_safe_value(value) } }
205
+ end
206
+
187
207
  # Neutralizes spreadsheet formula injection (CWE-1236) on CSV export by
188
208
  # prefixing a single quote to string cells that begin with a formula
189
209
  # trigger character. Non-string values (numbers, dates, booleans) are
data/lib/dbf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DBF
4
- VERSION = '5.4.0'
4
+ VERSION = '5.5.0'
5
5
  end
data/lib/dbf.rb CHANGED
@@ -7,6 +7,7 @@ require 'json'
7
7
  require 'time'
8
8
 
9
9
  require 'dbf/version'
10
+ require 'dbf/errors'
10
11
  require 'dbf/schema'
11
12
  require 'dbf/find'
12
13
  require 'dbf/record'
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: 5.4.0
4
+ version: 5.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Morrison
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: csv
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '3.3'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '3.3'
26
26
  description: A small fast library for reading dBase, xBase, Clipper and FoxPro database
27
27
  files.
28
28
  email: keithm@infused.org
@@ -44,6 +44,7 @@ files:
44
44
  - lib/dbf/database/foxpro.rb
45
45
  - lib/dbf/encoder.rb
46
46
  - lib/dbf/encodings.rb
47
+ - lib/dbf/errors.rb
47
48
  - lib/dbf/file_handler.rb
48
49
  - lib/dbf/find.rb
49
50
  - lib/dbf/header.rb
@@ -63,8 +64,12 @@ licenses:
63
64
  - MIT
64
65
  metadata:
65
66
  rubygems_mfa_required: 'true'
67
+ homepage_uri: https://github.com/infused/dbf
66
68
  source_code_uri: https://github.com/infused/dbf
67
69
  changelog_uri: https://github.com/infused/dbf/blob/main/CHANGELOG.md
70
+ bug_tracker_uri: https://github.com/infused/dbf/issues
71
+ documentation_uri: https://dbf.infused.org
72
+ funding_uri: https://github.com/sponsors/infused
68
73
  rdoc_options: []
69
74
  require_paths:
70
75
  - lib
@@ -79,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
84
  - !ruby/object:Gem::Version
80
85
  version: '0'
81
86
  requirements: []
82
- rubygems_version: 4.0.17
87
+ rubygems_version: 3.6.9
83
88
  specification_version: 4
84
89
  summary: Read xBase files
85
90
  test_files: []