dsv 0.12.4
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 +7 -0
- data/CHANGELOG +951 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/Rakefile +22 -0
- data/TODO +24 -0
- data/dsv.gemspec +43 -0
- data/lib/DSV/File.rb +35 -0
- data/lib/DSV/String.rb +19 -0
- data/lib/DSV/VERSION.rb +6 -0
- data/lib/dsv.rb +472 -0
- data/test/VERSION_test.rb +20 -0
- data/test/dsv_test.rb +702 -0
- data/test/gemspec_test.rb +42 -0
- data/test/helper.rb +9 -0
- metadata +97 -0
data/lib/dsv.rb
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# dsv.rb
|
|
2
|
+
# DSV
|
|
3
|
+
|
|
4
|
+
# Description: Delimiter-separated values: reading and writing CSV and its relatives, tabulated data to and from files and strings, with any delimiter on either side.
|
|
5
|
+
|
|
6
|
+
require 'stringio'
|
|
7
|
+
|
|
8
|
+
require 'DSV/VERSION'
|
|
9
|
+
require 'DSV/File'
|
|
10
|
+
require 'DSV/String'
|
|
11
|
+
|
|
12
|
+
class DSV
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
|
|
16
|
+
# The trailing Hash of an argument list, removed from it; or none.
|
|
17
|
+
def extract_options(args)
|
|
18
|
+
args.last.is_a?(::Hash) ? args.pop : {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A mode as Ruby's File spells it, from any of the spellings the library accepts, a Symbol included; unspecified is r.
|
|
22
|
+
def normalised_mode(mode)
|
|
23
|
+
case mode.to_s
|
|
24
|
+
when 'r', 'r+', 'w', 'w+', 'a', 'a+' then mode.to_s
|
|
25
|
+
when 'read_only', 'read-only', 'readonly' then 'r'
|
|
26
|
+
when 'rw', 'read_write', 'read-write', 'readwrite' then 'r+'
|
|
27
|
+
when 'write_only', 'write-only', 'writeonly' then 'w'
|
|
28
|
+
when 'append' then 'a'
|
|
29
|
+
else 'r'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def source_type(source)
|
|
34
|
+
if ::File.exist?(source)
|
|
35
|
+
DSV::File
|
|
36
|
+
else
|
|
37
|
+
DSV::String
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def open(source, *args, &block)
|
|
42
|
+
csv_file = new(source, *args)
|
|
43
|
+
if block
|
|
44
|
+
begin
|
|
45
|
+
yield csv_file
|
|
46
|
+
csv_file
|
|
47
|
+
ensure
|
|
48
|
+
csv_file.close
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
csv_file
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The class-level read, parse and each take a column selection before the options, as the instance-level ones take it as arguments.
|
|
56
|
+
def each(source, *args, &block)
|
|
57
|
+
options = DSV.extract_options(args)
|
|
58
|
+
new(source, options).each(*args, &block)
|
|
59
|
+
end
|
|
60
|
+
alias_method :foreach, :each
|
|
61
|
+
|
|
62
|
+
def collect(source, *args, &block)
|
|
63
|
+
new_collection = []
|
|
64
|
+
each(source, *args){|row| new_collection << block.call(row)}
|
|
65
|
+
new_collection
|
|
66
|
+
end
|
|
67
|
+
alias_method :map, :collect
|
|
68
|
+
|
|
69
|
+
def select(source, *args, &block)
|
|
70
|
+
new_collection = []
|
|
71
|
+
each(source, *args){|row| new_collection << row if block.call(row)}
|
|
72
|
+
new_collection
|
|
73
|
+
end
|
|
74
|
+
alias_method :find_all, :select
|
|
75
|
+
|
|
76
|
+
def reject(source, *args, &block)
|
|
77
|
+
new_collection = []
|
|
78
|
+
each(source, *args){|row| new_collection << row unless block.call(row)}
|
|
79
|
+
new_collection
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def detect(source, *args, &block)
|
|
83
|
+
each(source, *args){|row| return row if block.call(row)}
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
alias_method :find, :detect
|
|
87
|
+
|
|
88
|
+
def read(source, *args, &block)
|
|
89
|
+
if block
|
|
90
|
+
parse(source, *args, &block)
|
|
91
|
+
else
|
|
92
|
+
options = DSV.extract_options(args)
|
|
93
|
+
new(source, options).read_csv(*args)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
alias_method :read_csv, :read
|
|
97
|
+
|
|
98
|
+
def parse(source, *args, &block)
|
|
99
|
+
if block
|
|
100
|
+
each(source, *args, &block)
|
|
101
|
+
else
|
|
102
|
+
read(source, *args)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
alias_method :parse_csv, :parse
|
|
106
|
+
|
|
107
|
+
def write(source, *args)
|
|
108
|
+
new(source, *args).write_csv
|
|
109
|
+
end
|
|
110
|
+
alias_method :write_csv, :write
|
|
111
|
+
|
|
112
|
+
# The header row as names, or nil where there is none; first_row is the raw line.
|
|
113
|
+
def header_row(source, *args)
|
|
114
|
+
new(source, *args).attributes
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def first_row(source, *args)
|
|
118
|
+
new(source, *args).first_row
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def attributes(source, *args)
|
|
122
|
+
new(source, *args).attributes
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def columns(source, *args)
|
|
126
|
+
new(source, *args).columns
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_line(raw_row, *args) # For FasterCSV compatibility.
|
|
130
|
+
options = DSV.extract_options(args)
|
|
131
|
+
row_separator = options[:row_separator] || options[:row_sep] || "\n"
|
|
132
|
+
column_separator = options[:column_separator] || options[:col_sep] || ','
|
|
133
|
+
sc = DSV.new(raw_row, :quote => nil, :as_array => true, :row_separator => row_separator, :column_separator => column_separator)
|
|
134
|
+
sc.parse_row(raw_row)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
end # class << self
|
|
138
|
+
|
|
139
|
+
include Enumerable
|
|
140
|
+
|
|
141
|
+
attr_accessor :header_row, :mode, :quote, :row_separator, :selected_columns, :as_array, :rows
|
|
142
|
+
|
|
143
|
+
def initialize(source, *args)
|
|
144
|
+
@source = (
|
|
145
|
+
if source.is_a?(::String)
|
|
146
|
+
DSV.source_type(source).new(source, *args).source
|
|
147
|
+
else
|
|
148
|
+
source
|
|
149
|
+
end
|
|
150
|
+
)
|
|
151
|
+
options = DSV.extract_options(args)
|
|
152
|
+
@header_row = options[:header_row] || options[:headers] || options[:header] || false
|
|
153
|
+
@mode = DSV.normalised_mode(options[:mode])
|
|
154
|
+
@quote = options[:quote] || nil
|
|
155
|
+
@row_separator = options[:row_separator] || options[:row_sep] || "\n"
|
|
156
|
+
@column_separator = options[:column_separator] || options[:col_sep] || ','
|
|
157
|
+
@selected_columns = options[:selected_columns]
|
|
158
|
+
@as_array = options[:as_array] || false
|
|
159
|
+
@render_row = row_renderer
|
|
160
|
+
if options[:columns]
|
|
161
|
+
self.columns = options[:columns]
|
|
162
|
+
else
|
|
163
|
+
self.columns
|
|
164
|
+
end
|
|
165
|
+
@rows = []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def close
|
|
169
|
+
@source.close
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A call's own selection, or the instance's selected_columns: where the call gives none.
|
|
173
|
+
def selection(selected_columns)
|
|
174
|
+
selected_columns.empty? ? Array(@selected_columns) : selected_columns
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def read(*selected_columns, &block)
|
|
178
|
+
selected_columns = selection(selected_columns)
|
|
179
|
+
if block
|
|
180
|
+
parse(*selected_columns, &block)
|
|
181
|
+
else
|
|
182
|
+
read_header
|
|
183
|
+
@rows = []
|
|
184
|
+
@source.each(@row_separator){|raw_row| @rows << parse_row(complete_quoted_row(raw_row), *selected_columns)}
|
|
185
|
+
@rows
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
alias_method :read_csv, :read
|
|
189
|
+
|
|
190
|
+
def read_header
|
|
191
|
+
columns
|
|
192
|
+
if header_row?
|
|
193
|
+
(@source.rewind; @source.gets(@row_separator))
|
|
194
|
+
else
|
|
195
|
+
@source.rewind
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
alias_method :read_csv_header, :read_header
|
|
199
|
+
|
|
200
|
+
def parse(*selected_columns, &block)
|
|
201
|
+
if block
|
|
202
|
+
each(*selected_columns, &block)
|
|
203
|
+
else
|
|
204
|
+
read(*selected_columns)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
alias_method :parse_csv, :parse
|
|
208
|
+
|
|
209
|
+
# Memoised upon whether the header has been looked for rather than upon what it gave, since a source with no header row yields nil legitimately and ||= would look again, reading and rewinding the source, on every call.
|
|
210
|
+
def columns
|
|
211
|
+
return @columns if defined?(@columns)
|
|
212
|
+
@columns = (
|
|
213
|
+
if header_row? && ['r', 'r+', 'a+'].include?(@mode) && (first_row = first_row?)
|
|
214
|
+
columns, i = {}, -1
|
|
215
|
+
split_row(first_row).each do |column_name|
|
|
216
|
+
columns[column_name] = columns.key?(column_name) ? [*columns[column_name], i += 1] : (i += 1)
|
|
217
|
+
end
|
|
218
|
+
columns
|
|
219
|
+
else
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def columns=(*column_order)
|
|
226
|
+
@columns = {}
|
|
227
|
+
@attributes = @repeated_names = nil
|
|
228
|
+
column_order.flatten!
|
|
229
|
+
if column_order[0].is_a?(Hash)
|
|
230
|
+
column_order[0].each{|column_name, column_position| @columns[column_name.to_s] = column_position}
|
|
231
|
+
else
|
|
232
|
+
i = -1
|
|
233
|
+
column_order.each{|column| @columns[column.to_s] = @columns.key?(column.to_s) ? [*@columns[column.to_s], i += 1] : (i += 1)}
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The names that occur at more than one position; under such a name a row holds an Array of values, one per position, in order.
|
|
238
|
+
def repeated_names
|
|
239
|
+
@repeated_names ||= (columns || {}).select{|name, position| position.is_a?(::Array)}.keys
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A row's value for a name, keyed for the row: assigned where the name occurs once, gathered where it repeats.
|
|
243
|
+
def store_field(parsed_row, name, value)
|
|
244
|
+
if repeated_names.include?(name)
|
|
245
|
+
(parsed_row[name] ||= []) << value
|
|
246
|
+
else
|
|
247
|
+
parsed_row[name] = value
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# A row's values in position order: an Array under a repeated name is spread back over its positions, and a single value under one is written at each.
|
|
252
|
+
def values_in_order(row)
|
|
253
|
+
occurrence = ::Hash.new(0)
|
|
254
|
+
attributes.collect do |name|
|
|
255
|
+
value = row[name]
|
|
256
|
+
value = value[occurrence[name]] if value.is_a?(::Array) && repeated_names.include?(name)
|
|
257
|
+
occurrence[name] += 1
|
|
258
|
+
value
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# nil or empty, which a Hash of columns, an Array of names and an unset selection all may be.
|
|
263
|
+
def blank?(value)
|
|
264
|
+
value.nil? || value.empty?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# One row into its fields, by the quote mode: :none splits; :double splits a wholly quoted row on quote-separator-quote and a wholly unquoted one plainly; unspecified splits and reassembles a quoted field that held the separator, a doubled quote inside it reading as one. Direct string operations throughout; the scanner-based parser is on the branch scanner-parser.
|
|
268
|
+
def split_row(raw_row)
|
|
269
|
+
row = raw_row.chomp(@row_separator)
|
|
270
|
+
case @quote
|
|
271
|
+
when :none, :unquoted
|
|
272
|
+
row.split(@column_separator, -1)
|
|
273
|
+
when :double, :double_quoted, :double_quotes
|
|
274
|
+
if row.start_with?('"')
|
|
275
|
+
row.delete_prefix('"').delete_suffix('"').split('"' + @column_separator + '"', -1).collect{|field| field.gsub('""', '"')}
|
|
276
|
+
else
|
|
277
|
+
row.split(@column_separator, -1)
|
|
278
|
+
end
|
|
279
|
+
else
|
|
280
|
+
reassemble_quoted_fields(row.split(@column_separator, -1))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Pieces of a row split on the separator, the pieces of a quoted field that held the separator joined back with it; a piece that is a lone quote opens or closes a field.
|
|
285
|
+
def reassemble_quoted_fields(pieces)
|
|
286
|
+
fields = []
|
|
287
|
+
buffer = nil
|
|
288
|
+
pieces.each do |piece|
|
|
289
|
+
if buffer && !piece.end_with?('"')
|
|
290
|
+
buffer << @column_separator << piece
|
|
291
|
+
elsif buffer
|
|
292
|
+
fields << (buffer << @column_separator << piece.delete_suffix('"')).gsub('""', '"')
|
|
293
|
+
buffer = nil
|
|
294
|
+
elsif piece.start_with?('"') && (piece.length == 1 || !piece.end_with?('"'))
|
|
295
|
+
buffer = +piece.delete_prefix('"')
|
|
296
|
+
elsif piece.start_with?('"') && piece.end_with?('"')
|
|
297
|
+
fields << piece.delete_prefix('"').delete_suffix('"').gsub('""', '"')
|
|
298
|
+
else
|
|
299
|
+
fields << piece
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
fields
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A quoted field may hold the row separator: while the quotes in a row are unbalanced the next line belongs to it.
|
|
306
|
+
def complete_quoted_row(raw_row)
|
|
307
|
+
raw_row << @source.gets(@row_separator).to_s while @quote.nil? && raw_row.count('"').odd? && !@source.eof?
|
|
308
|
+
raw_row
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def parse_row(raw_row, *selected_columns)
|
|
312
|
+
parsed_row = {}
|
|
313
|
+
i = -1
|
|
314
|
+
if selected_columns.empty?
|
|
315
|
+
if blank?(@columns)
|
|
316
|
+
split_row(raw_row).each{|column_value| parsed_row[i += 1] = column_value}
|
|
317
|
+
elsif repeated_names.empty?
|
|
318
|
+
split_row(raw_row).each{|column_value| parsed_row[attributes[i += 1]] = column_value}
|
|
319
|
+
else
|
|
320
|
+
split_row(raw_row).each{|column_value| store_field(parsed_row, attributes[i += 1], column_value)}
|
|
321
|
+
end
|
|
322
|
+
else
|
|
323
|
+
selected_columns.flatten!
|
|
324
|
+
case selected_columns[0]
|
|
325
|
+
when Integer
|
|
326
|
+
split_row(raw_row).each{|column_value| parsed_row[i] = column_value unless !selected_columns.include?(i += 1)}
|
|
327
|
+
else
|
|
328
|
+
split_row(raw_row).each{|column_value| store_field(parsed_row, attributes[i], column_value) unless !selected_columns.include?(attributes[i += 1])}
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
if @as_array
|
|
332
|
+
if blank?(@columns)
|
|
333
|
+
(0..(parsed_row.size - 1)).inject([]){|a,i| a << parsed_row[i]}
|
|
334
|
+
else
|
|
335
|
+
values_in_order(parsed_row)
|
|
336
|
+
end
|
|
337
|
+
else
|
|
338
|
+
parsed_row
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# With no columns defined, rows keyed by name supply them, and the header, from the first row's keys; rows keyed by position write positionally and have no header to write.
|
|
343
|
+
def write(*selected_columns)
|
|
344
|
+
prepare_to_rewrite if @mode == 'r+'
|
|
345
|
+
self.columns = @rows[0].keys if blank?(columns) && @rows[0].is_a?(::Hash) && @rows[0].keys[0].is_a?(::String)
|
|
346
|
+
write_header(*selected_columns) if header_row? && attributes
|
|
347
|
+
each{|row| write_row(row, *selected_columns)}
|
|
348
|
+
end
|
|
349
|
+
alias_method :write_csv, :write
|
|
350
|
+
|
|
351
|
+
# Under r+ a write replaces the file: the rows are read first if they have not been, then the file is emptied, once, here, rather than at the end of every read.
|
|
352
|
+
def prepare_to_rewrite
|
|
353
|
+
read_csv unless @rows[0]
|
|
354
|
+
@source.rewind
|
|
355
|
+
@source.truncate(0)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def write_header(*selected_columns)
|
|
359
|
+
selected_columns.flatten!
|
|
360
|
+
names = selected_columns.empty? ? attributes : selected_columns
|
|
361
|
+
write_values(names)
|
|
362
|
+
end
|
|
363
|
+
alias_method :write_csv_header, :write_header
|
|
364
|
+
|
|
365
|
+
# A nil value is an empty field, so the columns after it keep their places; with no columns defined a row's values are written in the row's own order.
|
|
366
|
+
def write_row(row, *selected_columns)
|
|
367
|
+
selected_columns.flatten!
|
|
368
|
+
values = if !selected_columns.empty?
|
|
369
|
+
selected_columns.collect{|column| row[column]}
|
|
370
|
+
elsif attributes
|
|
371
|
+
values_in_order(row)
|
|
372
|
+
else
|
|
373
|
+
row.values
|
|
374
|
+
end
|
|
375
|
+
write_values(values)
|
|
376
|
+
end
|
|
377
|
+
alias_method :write_csv_row, :write_row
|
|
378
|
+
|
|
379
|
+
def write_values(values)
|
|
380
|
+
@source.write(@render_row.call(values))
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Chosen once from the quote mode: how a row of values is rendered, with the instance's separators, a quote inside a quoted value doubled as RFC 4180 has it. The spacey modes put a space after each separator, as Array#to_csv_row did.
|
|
384
|
+
def row_renderer
|
|
385
|
+
quote, spacey = case @quote&.to_sym
|
|
386
|
+
when :none, :unquoted then [nil, false]
|
|
387
|
+
when :spacey_none, :spacey_unquoted then [nil, true]
|
|
388
|
+
when :single then ["'", false]
|
|
389
|
+
when :spacey_single then ["'", true]
|
|
390
|
+
when :spacey_double then ['"', true]
|
|
391
|
+
else ['"', false]
|
|
392
|
+
end
|
|
393
|
+
joiner = spacey ? @column_separator + ' ' : @column_separator
|
|
394
|
+
if quote
|
|
395
|
+
doubled = quote * 2
|
|
396
|
+
->(values){values.collect{|value| quote + value.to_s.gsub(quote, doubled) + quote}.join(joiner) + @row_separator}
|
|
397
|
+
else
|
|
398
|
+
->(values){values.join(joiner) + @row_separator}
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def each(*selected_columns)
|
|
403
|
+
return to_enum(:each, *selected_columns) unless block_given?
|
|
404
|
+
selected_columns = selection(selected_columns)
|
|
405
|
+
selected_columns.flatten!
|
|
406
|
+
if @rows[0]
|
|
407
|
+
if selected_columns.empty?
|
|
408
|
+
@rows.each{|row| yield row}
|
|
409
|
+
else
|
|
410
|
+
@rows.each do |row|
|
|
411
|
+
yield selected_columns.inject({}){|hash, column_name| hash[column_name] = row[column_name]; hash}
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
else
|
|
415
|
+
if selected_columns.empty?
|
|
416
|
+
read_csv.each{|row| yield row}
|
|
417
|
+
else
|
|
418
|
+
read_csv(selected_columns).each do |row|
|
|
419
|
+
yield selected_columns.inject({}){|hash, column_name| hash[column_name] = row[column_name]; hash}
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
alias_method :each_row, :each
|
|
425
|
+
|
|
426
|
+
def attributes
|
|
427
|
+
@attributes ||= (
|
|
428
|
+
if blank?(columns)
|
|
429
|
+
nil
|
|
430
|
+
else
|
|
431
|
+
a = []
|
|
432
|
+
columns.each do |k,v|
|
|
433
|
+
case v
|
|
434
|
+
when Array
|
|
435
|
+
v.each{|e| a << [k, e]}
|
|
436
|
+
else
|
|
437
|
+
a << [k, v]
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
a.sort{|a,b| a[1] <=> b[1]}.collect{|a| a[0]}
|
|
441
|
+
end
|
|
442
|
+
)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def attributes=(attributes)
|
|
446
|
+
@attributes = attributes
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def header_row?
|
|
450
|
+
@header_row
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def first_row
|
|
454
|
+
@source.rewind
|
|
455
|
+
return_value = @source.gets(@row_separator)
|
|
456
|
+
@source.rewind
|
|
457
|
+
return_value
|
|
458
|
+
end
|
|
459
|
+
alias_method :first_row?, :first_row
|
|
460
|
+
|
|
461
|
+
def to_a
|
|
462
|
+
read_csv unless @rows[0]
|
|
463
|
+
if @as_array
|
|
464
|
+
@rows
|
|
465
|
+
elsif blank?(@columns)
|
|
466
|
+
@rows.collect(&:values)
|
|
467
|
+
else
|
|
468
|
+
@rows.collect{|row| values_in_order(row)}
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
end # class DSV
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# test/VERSION_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative './helper'
|
|
4
|
+
|
|
5
|
+
describe DSV do
|
|
6
|
+
describe "VERSION" do
|
|
7
|
+
it "is a string" do
|
|
8
|
+
_(DSV::VERSION).must_be_instance_of String
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "is three numbers separated by dots" do
|
|
12
|
+
_(DSV::VERSION).must_match(/\A\d+\.\d+\.\d+\z/)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "matches the newest entry in the CHANGELOG" do
|
|
16
|
+
changelog = File.read(File.expand_path('../CHANGELOG', __dir__))
|
|
17
|
+
_(changelog[/^(\d+\.\d+\.\d+):/, 1]).must_equal(DSV::VERSION)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|