csv-utils 0.5.0 → 0.6.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.
@@ -1,93 +1,117 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # CSVUtils::CSVCompare purpose is to determine which rows in the secondary_data_file need to be created, deleted or updated
4
- # **requires both CSV files to be sorted on the same columns, CSVUtils::CSVSort can accomplish this
5
- # In order to receive updates, update_comparison_columns must configured or use inheritance and change the update_row? method
6
3
  module CSVUtils
4
+ # Compares two CSV files sorted on the same key and yields what it takes to make the secondary file
5
+ # match the primary one: records to create, update or delete. Sort both files first, {CSVSort} can do it.
6
+ #
7
+ # Updates are only reported for the update_comparison_columns; subclass and override update_row? for other rules.
8
+ #
9
+ # @example
10
+ # comparer = CSVUtils::CSVCompare.new('primary.csv', ['updated_at']) { |src, dest| src['id'].to_i <=> dest['id'].to_i }
11
+ # comparer.compare('secondary.csv') { |action, record| puts "#{action} #{record['id']}" }
7
12
  class CSVCompare
8
- # primary_data_file is the source of truth
9
- # compare_proc used to compare the id column(s)
10
- # update_comparison_columns column(s) to compare for equality, ex: updated_at, timestamp, hash
11
- # caveat: update_comparison_columns need to be in both csv files
12
- attr_reader :primary_data_file,
13
- :update_comparison_columns,
14
- :compare_proc
13
+ # @return [String] path of the source of truth
14
+ attr_reader :primary_data_file
15
+ # @return [Array<String>, nil] columns, present in both files, that trigger an update when they differ
16
+ # (ex: updated_at, a timestamp or a hash of the row)
17
+ attr_reader :update_comparison_columns
18
+ # @return [Proc] compares the key columns of a primary and a secondary record, returning -1, 0 or 1
19
+ attr_reader :compare_proc
20
+ # @return [Hash] options passed to CSV.open for both files
21
+ attr_reader :csv_options
15
22
 
16
- def initialize(primary_data_file, update_comparison_columns = nil, &block)
23
+ # @param primary_data_file [String] path of the source of truth
24
+ # @param update_comparison_columns [Array<String>, nil] columns compared on matching records;
25
+ # without them no updates are yielded
26
+ # @param csv_options [Hash] options passed to CSV.open for both files, ex: col_sep: "\t",
27
+ # or encoding: 'BINARY' to read bytes that aren't valid UTF-8
28
+ # @yieldparam src [Hash{String => String}] record from the primary file
29
+ # @yieldparam dest [Hash{String => String}] record from the secondary file
30
+ # @yieldreturn [Integer] negative, 0 or positive like <=>, using the same order both files are sorted by
31
+ def initialize(primary_data_file, update_comparison_columns = nil, csv_options = {}, &block)
17
32
  @primary_data_file = primary_data_file
18
33
  @update_comparison_columns = update_comparison_columns
34
+ @csv_options = csv_options
19
35
  @compare_proc = block
20
36
  end
21
37
 
22
- # rubocop:disable Metrics/MethodLength
23
- def compare(secondary_data_file)
24
- src = CSV.open(primary_data_file, 'rb')
25
- begin
26
- src_headers = src.shift
27
- strip_bom!(src_headers[0])
28
- dest = CSV.open(secondary_data_file, 'rb')
29
- begin
30
- dest_headers = dest.shift
31
- strip_bom!(dest_headers[0])
38
+ # Walks both files and yields each difference.
39
+ # @param secondary_data_file [String] path of the file to bring in line with the primary file
40
+ # @yieldparam action [Symbol] :create (only in the primary file), :update (in both, with changed
41
+ # update_comparison_columns) or :delete (only in the secondary file)
42
+ # @yieldparam record [Hash{String => String}] the primary record for :create and :update,
43
+ # the secondary record for :delete
44
+ # @return [void]
45
+ def compare(secondary_data_file, &)
46
+ open_csv(primary_data_file) do |src, src_headers|
47
+ open_csv(secondary_data_file) do |dest, dest_headers|
48
+ compare_records(src, src_headers, dest, dest_headers, &)
49
+ end
50
+ end
51
+ end
32
52
 
33
- read_next_src = true
34
- read_next_dest = true
53
+ private
35
54
 
36
- while !src.eof? || !dest.eof?
37
- src_record = next_record_from_file(src_headers, src) if read_next_src
38
- dest_record = next_record_from_file(dest_headers, dest) if read_next_dest
55
+ # walks both sorted files at once, advancing whichever side is behind
56
+ def compare_records(src, src_headers, dest, dest_headers)
57
+ src_record = next_record(src_headers, src)
58
+ dest_record = next_record(dest_headers, dest)
39
59
 
40
- if !src_record
41
- read_next_src = false
42
- read_next_dest = true
43
- yield :delete, dest_record
44
- elsif !dest_record
45
- read_next_src = true
46
- read_next_dest = false
47
- yield :create, src_record
48
- elsif compare_proc.call(src_record, dest_record).zero?
49
- read_next_src = true
50
- read_next_dest = true
51
- yield(:update, src_record) if update_row?(src_record, dest_record)
52
- elsif compare_proc.call(src_record, dest_record).positive?
53
- read_next_src = false
54
- read_next_dest = true
55
- yield :delete, dest_record
56
- else
57
- read_next_src = true
58
- read_next_dest = false
59
- yield :create, src_record
60
- end
61
- end
62
- ensure
63
- dest.close
60
+ while src_record || dest_record
61
+ case record_action(src_record, dest_record)
62
+ when :create
63
+ yield :create, src_record
64
+ src_record = next_record(src_headers, src)
65
+ when :delete
66
+ yield :delete, dest_record
67
+ dest_record = next_record(dest_headers, dest)
68
+ else
69
+ yield :update, src_record if update_row?(src_record, dest_record)
70
+ src_record = next_record(src_headers, src)
71
+ dest_record = next_record(dest_headers, dest)
64
72
  end
65
- ensure
66
- src.close
67
73
  end
68
74
  end
69
- # rubocop:enable Metrics/MethodLength
70
75
 
71
- private
76
+ def record_action(src_record, dest_record)
77
+ return :delete unless src_record
78
+ return :create unless dest_record
72
79
 
73
- def next_record_from_file(headers, file)
74
- return nil if file.eof?
80
+ result = compare_proc.call(src_record, dest_record)
81
+ if result.zero?
82
+ :match
83
+ elsif result.positive?
84
+ :delete
85
+ else
86
+ :create
87
+ end
88
+ end
75
89
 
76
- headers.zip(file.shift).to_h
90
+ def open_csv(file)
91
+ csv = CSV.open(file, 'rb', **csv_options)
92
+ yield csv, read_headers(csv)
93
+ ensure
94
+ csv&.close
77
95
  end
78
96
 
79
- def update_row?(src_record, dest_record)
80
- return false unless update_comparison_columns
97
+ def read_headers(csv)
98
+ headers = csv.shift || []
99
+ headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
100
+ headers
101
+ end
81
102
 
82
- update_comparison_columns.each do |column_name|
83
- return true unless src_record[column_name] == dest_record[column_name]
84
- end
103
+ def next_record(headers, csv)
104
+ return unless (row = csv.shift)
85
105
 
86
- false
106
+ headers.zip(row).to_h
87
107
  end
88
108
 
89
- def strip_bom!(col)
90
- col.sub!((+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT'), '')
109
+ def update_row?(src_record, dest_record)
110
+ return false unless update_comparison_columns
111
+
112
+ update_comparison_columns.any? do |column_name|
113
+ src_record[column_name] != dest_record[column_name]
114
+ end
91
115
  end
92
116
  end
93
117
  end
@@ -1,13 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Utility class for appending data to a csv file.
4
3
  module CSVUtils
4
+ # Copies a CSV file to a new one with extra columns added to every row.
5
+ #
6
+ # @example
7
+ # extender = CSVUtils::CSVExtender.new('input.csv', 'output.csv')
8
+ # extender.append(['name_length']) { |row, headers| [row[headers.index('name')].size] }
5
9
  class CSVExtender
10
+ # @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the extender
11
+ # @param dest_csv [String, CSV, #<<] path or CSV to write
12
+ # @param csv_options [Hash] options passed to CSV.open for paths
6
13
  def initialize(src_csv, dest_csv, csv_options = {})
7
14
  @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
8
15
  @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
9
16
  end
10
17
 
18
+ # Appends columns one row at a time, then closes the files it opened.
19
+ # @param additional_headers [Array<String>, nil] headers to add; nil when the source has no header row
20
+ # @yieldparam row [Array<String>] the source row
21
+ # @yieldparam headers [Array<String>, nil] the source headers
22
+ # @yieldreturn [Array] values to append to the row
23
+ # @return [void]
11
24
  def append(additional_headers)
12
25
  process(additional_headers) do |current_headers|
13
26
  while (row = @src_csv.shift)
@@ -17,6 +30,14 @@ module CSVUtils
17
30
  end
18
31
  end
19
32
 
33
+ # Appends columns a batch of rows at a time, for lookups that are cheaper in bulk
34
+ # (ex: one database query per batch), then closes the files it opened.
35
+ # @param additional_headers [Array<String>, nil] headers to add; nil when the source has no header row
36
+ # @param batch_size [Integer] rows per batch
37
+ # @yieldparam batch [Array<Array<String>>] the source rows
38
+ # @yieldparam headers [Array<String>, nil] the source headers
39
+ # @yieldreturn [Array<Array>] values to append, one array per row in the batch
40
+ # @return [void]
20
41
  def append_in_batches(additional_headers, batch_size = 1_000)
21
42
  process(additional_headers) do |current_headers|
22
43
  batch = []
@@ -47,7 +68,7 @@ module CSVUtils
47
68
  current_headers = append_headers(additional_headers)
48
69
 
49
70
  yield current_headers
50
-
71
+ ensure
51
72
  close
52
73
  end
53
74
 
@@ -1,29 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Search a CSV given a series of steps
4
3
  module CSVUtils
4
+ # Enumerates the rows of a CSV file as hashes keyed by header, with the line number of each row.
5
+ #
6
+ # @example
7
+ # iterator = CSVUtils::CSVIterator.new('data.csv')
8
+ # iterator.each { |row| puts "#{row.lineno}: #{row['name']}" }
9
+ # iterator.select { |row| row['status'] == 'active' }
5
10
  class CSVIterator
6
11
  include Enumerable
7
12
 
8
- BYTE_ORDER_MARKS = [
9
- (+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT'), # UTF-8
10
- (+"\xFE\xFF").force_encoding('ASCII-8BIT'), # UTF-16 BE
11
- (+"\xFF\xFE").force_encoding('ASCII-8BIT'), # UTF-16 LE
12
- (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT'), # UTF-32 BE
13
- (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT') # UTF-32 LE
14
- ].freeze
13
+ # @return [Array<String>] the byte order marks stripped from the first header, as binary strings
14
+ BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS.keys.freeze
15
15
 
16
+ # @return [Array<String>, nil] the raw row read before the current one, for debugging malformed rows
16
17
  attr_reader :prev_row
17
18
 
19
+ # A row as a hash of header to value that knows its line number in the file.
18
20
  class RowWrapper < Hash
21
+ # @return [Integer] line number of the row in the file, counting the header row as line 1
19
22
  attr_accessor :lineno
20
23
 
24
+ # @param headers [Array<String>]
25
+ # @param row [Array<String>]
26
+ # @param lineno [Integer]
27
+ # @return [RowWrapper]
21
28
  def self.create(headers, row, lineno)
22
29
  row_wrapper = RowWrapper[headers.zip(row)]
23
30
  row_wrapper.lineno = lineno
24
31
  row_wrapper
25
32
  end
26
33
 
34
+ # Numbered "header: value" lines for the non-blank values, as csv-grep prints them.
35
+ # @return [String]
27
36
  def to_pretty_s
28
37
  reject { |_, v| v.nil? || v.strip.empty? }
29
38
  .each_with_index
@@ -32,17 +41,25 @@ module CSVUtils
32
41
  end
33
42
  end
34
43
 
44
+ # @param src_csv [String, CSV] path or CSV to read; it must support rewind
45
+ # @param csv_options [Hash] options passed to CSV.open for a path
46
+ # @param mode [String] file mode for a path; 'rb:BINARY' reads bytes as is
35
47
  def initialize(src_csv, csv_options = {}, mode = 'rb')
36
48
  @src_csv = CSVUtils::CSVWrapper.new(src_csv, mode, csv_options)
37
49
  end
38
50
 
51
+ # Yields each row from the start of the file.
52
+ # @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
53
+ # @yieldparam row [RowWrapper]
54
+ # @return [Enumerator, nil] an enumerator without a block
39
55
  def each(headers = nil)
56
+ return enum_for(:each, headers) unless block_given?
57
+
40
58
  @src_csv.rewind
41
59
 
42
60
  lineno = 0
43
61
  unless headers
44
- headers = @src_csv.shift
45
- strip_bom!(headers[0])
62
+ headers = read_headers
46
63
  lineno += 1
47
64
  end
48
65
 
@@ -54,13 +71,19 @@ module CSVUtils
54
71
  end
55
72
  end
56
73
 
74
+ # The first row, without the byte order mark.
75
+ # @return [Array<String>] empty for an empty file
57
76
  def headers
58
77
  @src_csv.rewind
59
- headers = @src_csv.shift
60
- strip_bom!(headers[0])
61
- headers
78
+ read_headers
62
79
  end
63
80
 
81
+ # Builds a lookup hash from every row.
82
+ # @param key [String] header whose value is the hash key
83
+ # @param value [String, nil] header whose value is the hash value
84
+ # @yieldparam row [RowWrapper] used instead of value to compute the hash value
85
+ # @return [Hash]
86
+ # @raise [RuntimeError] when key or value isn't a header
64
87
  def to_hash(key, value = nil, &)
65
88
  raise("header #{key} not found in #{headers}") unless headers.include?(key)
66
89
  raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
@@ -72,11 +95,13 @@ module CSVUtils
72
95
  proc(&)
73
96
  end
74
97
 
75
- each_with_object({}) do |row, hsh|
76
- hsh[row[key]] = value_proc.call(row)
98
+ to_h do |row|
99
+ [row[key], value_proc.call(row)]
77
100
  end
78
101
  end
79
102
 
103
+ # Number of rows after the header row.
104
+ # @return [Integer]
80
105
  def size
81
106
  @src_csv.rewind
82
107
  @src_csv.shift
@@ -85,6 +110,10 @@ module CSVUtils
85
110
  cnt
86
111
  end
87
112
 
113
+ # Yields the rows in batches.
114
+ # @param batch_size [Integer] rows per batch; the last batch may be smaller
115
+ # @yieldparam batch [Array<RowWrapper>]
116
+ # @return [nil]
88
117
  def each_batch(batch_size = 1_000)
89
118
  batch = []
90
119
 
@@ -105,13 +134,11 @@ module CSVUtils
105
134
 
106
135
  private
107
136
 
108
- def strip_bom!(col)
109
- BYTE_ORDER_MARKS.each do |bom|
110
- if col.start_with?(bom)
111
- col.sub!(bom, '')
112
- break
113
- end
114
- end
137
+ # an empty file has no headers, and an empty first header cell is nil
138
+ def read_headers
139
+ headers = @src_csv.shift || []
140
+ headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
141
+ headers
115
142
  end
116
143
  end
117
144
  end
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Auto detect a csv files options
4
3
  module CSVUtils
4
+ # Detects the separators, byte order mark, encoding and column count of a CSV file from its first line.
5
+ #
6
+ # @example
7
+ # options = CSVUtils::CSVOptions.new('data.csv')
8
+ # CSV.open('data.csv', 'rb', col_sep: options.col_separator) if options.valid?
5
9
  class CSVOptions
6
- # this list is from https://en.wikipedia.org/wiki/Byte_order_mark
7
- BYTE_ORDER_MARKS = {
8
- (+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT') => 'UTF-8',
9
- (+"\xFE\xFF").force_encoding('ASCII-8BIT') => 'UTF-16',
10
- (+"\xFF\xFE").force_encoding('ASCII-8BIT') => 'UTF-16',
11
- (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT') => 'UTF-32',
12
- (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT') => 'UTF-32'
13
- }.freeze
10
+ # @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
11
+ BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
14
12
 
13
+ # @return [Array<String>] column separators to look for, the first one found in the line wins
15
14
  COL_SEPARATORS = [
16
15
  "\x02",
17
16
  "\t",
@@ -19,25 +18,27 @@ module CSVUtils
19
18
  ','
20
19
  ].freeze
21
20
 
21
+ # @return [Array<String>] row separators to look for, the first one found in the line wins
22
22
  ROW_SEPARATORS = [
23
23
  "\r\n",
24
24
  "\n",
25
25
  "\r"
26
26
  ].freeze
27
27
 
28
- attr_reader :columns,
29
- :byte_order_mark,
30
- :encoding,
31
- :col_separator,
32
- :row_separator
28
+ # @return [Integer, nil] number of headers, nil without a column separator
29
+ attr_reader :columns
30
+ # @return [String, nil] the byte order mark the file starts with, as a binary string
31
+ attr_reader :byte_order_mark
32
+ # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32', from the byte order mark; 'UTF-8' without one
33
+ attr_reader :encoding
34
+ # @return [String, nil] one of {COL_SEPARATORS}
35
+ attr_reader :col_separator
36
+ # @return [String, nil] one of {ROW_SEPARATORS}
37
+ attr_reader :row_separator
33
38
 
39
+ # @param io [String, IO] path of the file, or an IO positioned at its first line
34
40
  def initialize(io)
35
- line =
36
- if io.is_a?(String)
37
- File.open(io, 'rb', &:readline)
38
- else
39
- io.readline
40
- end
41
+ line = read_first_line(io)
41
42
 
42
43
  @col_separator = auto_detect_col_sep(line)
43
44
  @row_separator = auto_detect_row_sep(line)
@@ -46,42 +47,79 @@ module CSVUtils
46
47
  @columns = get_number_of_columns(line) if @col_separator
47
48
  end
48
49
 
50
+ # Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
51
+ # @return [Boolean]
49
52
  def valid?
50
53
  return false if @col_separator.nil? || @row_separator.nil?
51
54
 
52
55
  true
53
56
  end
54
57
 
58
+ # @api private
59
+ # @param line [String]
60
+ # @return [String, nil]
55
61
  def auto_detect_col_sep(line)
56
62
  COL_SEPARATORS.detect { |sep| line.include?(sep) }
57
63
  end
58
64
 
65
+ # @api private
66
+ # @param line [String]
67
+ # @return [String, nil]
59
68
  def auto_detect_row_sep(line)
60
69
  ROW_SEPARATORS.detect { |sep| line.include?(sep) }
61
70
  end
62
71
 
72
+ # Parses the header line like a CSV row, so quoted headers can contain the separator,
73
+ # and falls back to a plain split when the line is malformed.
74
+ # @param line [String] the first line of the file
75
+ # @return [Array<String, nil>] headers without the byte order mark or row separator
63
76
  def get_headers(line)
64
- headers = line.split(col_separator)
65
- headers[0] = strip_byte_order_marks(headers[0])
66
- headers
77
+ line = strip_byte_order_marks(line)
78
+ CSV.parse_line(line, **header_parse_options) || []
79
+ rescue CSV::MalformedCSVError
80
+ line.chomp(row_separator.to_s).split(col_separator, -1)
67
81
  end
68
82
 
83
+ # @api private
84
+ # @param line [String]
85
+ # @return [Integer]
69
86
  def get_number_of_columns(line)
70
87
  get_headers(line).size
71
88
  end
72
89
 
90
+ # @api private
91
+ # @param line [String]
92
+ # @return [String, nil]
73
93
  def get_byte_order_mark(line)
74
- BYTE_ORDER_MARKS.keys.detect do |bom|
75
- line =~ /\A#{bom}/
76
- end
94
+ ByteOrderMark.detect(line)
77
95
  end
78
96
 
97
+ # @api private
98
+ # @param bom [String, nil]
99
+ # @return [String]
79
100
  def get_character_encoding(bom)
80
101
  BYTE_ORDER_MARKS[bom] || 'UTF-8'
81
102
  end
82
103
 
104
+ # @api private
105
+ # @param header [String]
106
+ # @return [String]
83
107
  def strip_byte_order_marks(header)
84
- @byte_order_mark ? header.sub(@byte_order_mark, '') : header
108
+ ByteOrderMark.strip(header)
109
+ end
110
+
111
+ private
112
+
113
+ # an empty file reads as an empty line, which makes the options invalid
114
+ def read_first_line(io)
115
+ line = io.is_a?(String) ? File.open(io, 'rb', &:gets) : io.gets
116
+ line || ''
117
+ end
118
+
119
+ def header_parse_options
120
+ options = { col_sep: col_separator }
121
+ options[:row_sep] = row_separator if row_separator
122
+ options
85
123
  end
86
124
  end
87
125
  end
@@ -1,11 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Builds a csv file from csv rows
4
3
  module CSVUtils
4
+ # Writes a CSV file from arrays or {CSVRow} objects.
5
+ #
6
+ # @example
7
+ # CSVUtils::CSVReport.new('users.csv', UserCSVRow) do |report|
8
+ # users.each { |user| report << user }
9
+ # end
5
10
  class CSVReport
6
- attr_reader :csv,
7
- :must_close
11
+ # @return [CSV, #<<] where rows are written
12
+ attr_reader :csv
13
+ # @return [Boolean] whether the report opened the file and closes it after {#generate}
14
+ attr_reader :must_close
8
15
 
16
+ # @param csv [String, CSV, #<<] path to write, opened in 'wb' mode by default, or a CSV to append to
17
+ # @param headers [Array<String>, Class, nil] header row, or a {CSVRow} class to take the headers from
18
+ # @param csv_options [Hash] options passed to CSV.open for a path; :mode overrides the file mode
19
+ # @yieldparam report [CSVReport] when given, the block is passed to {#generate}
9
20
  def initialize(csv, headers = nil, csv_options = {}, &block)
10
21
  @csv =
11
22
  if csv.is_a?(String)
@@ -23,11 +34,18 @@ module CSVUtils
23
34
  generate(&block) if block
24
35
  end
25
36
 
37
+ # Yields the report, then closes the file if the report opened it, even when the block raises.
38
+ # @yieldparam report [CSVReport]
39
+ # @return [void]
26
40
  def generate
27
41
  yield self
42
+ ensure
28
43
  close if @must_close
29
44
  end
30
45
 
46
+ # Writes a row.
47
+ # @param csv_row [Array, #to_a] an array of values or a {CSVRow} object
48
+ # @return [CSV, #<<]
31
49
  def append(csv_row)
32
50
  @csv <<
33
51
  if csv_row.is_a?(Array)
@@ -38,10 +56,15 @@ module CSVUtils
38
56
  end
39
57
  alias << append
40
58
 
59
+ # Writes the header row.
60
+ # @param csv_row [Array<String>, #csv_headers] headers, or a {CSVRow} class or object to take them from
61
+ # @return [CSV, #<<]
41
62
  def add_headers(csv_row)
42
63
  append(csv_row.is_a?(Array) ? csv_row : csv_row.csv_headers)
43
64
  end
44
65
 
66
+ # Closes the underlying CSV.
67
+ # @return [void]
45
68
  def close
46
69
  @csv.close
47
70
  end
@@ -3,18 +3,44 @@
3
3
  require 'inheritance-helper'
4
4
 
5
5
  module CSVUtils
6
+ # Mixin that declares how an object turns into a CSV row, for use with {CSVReport}.
7
+ # Columns are inherited by subclasses.
8
+ #
9
+ # @example
10
+ # class UserCSVRow < User
11
+ # include CSVUtils::CSVRow
12
+ #
13
+ # csv_column :id, header: 'ID'
14
+ # csv_column :name
15
+ # csv_column(:email) { email.downcase }
16
+ # end
6
17
  module CSVRow
18
+ # @api private
19
+ # @param base [Class]
20
+ # @return [void]
7
21
  def self.included(base)
8
22
  base.extend InheritanceHelper::Methods
9
23
  base.extend ClassMethods
10
24
  end
11
25
 
26
+ # Class level DSL for declaring columns.
12
27
  module ClassMethods
28
+ # Declared columns, keyed by name, in declaration order.
29
+ # @return [Hash{Symbol => Hash}]
13
30
  def csv_columns
14
31
  {}
15
32
  end
16
33
 
34
+ # Declares a column.
35
+ # @param header [Symbol, String] column name; also the default header and the method that provides the value
36
+ # @param options [Hash]
37
+ # @option options [String] :header header text
38
+ # @option options [Symbol] :method method that provides the value
39
+ # @option options [Proc] :proc evaluated on the object to provide the value
40
+ # @yield evaluated on the object to provide the value, like :proc
41
+ # @return [void]
17
42
  def csv_column(header, options = {}, &block)
43
+ options = options.dup
18
44
  options[:header] ||= header.to_s
19
45
 
20
46
  if block
@@ -26,6 +52,7 @@ module CSVUtils
26
52
  add_value_to_class_method(:csv_columns, header => options)
27
53
  end
28
54
 
55
+ # @return [Array<String>] the header of each column
29
56
  def csv_headers
30
57
  csv_columns.values.map { |column_options| csv_column_header(column_options) }
31
58
  end
@@ -37,11 +64,14 @@ module CSVUtils
37
64
  end
38
65
  end
39
66
 
67
+ # The value of each column.
68
+ # @return [Array]
40
69
  def csv_row
41
70
  self.class.csv_columns.values.map { |column_options| csv_column_value(column_options) }
42
71
  end
43
72
  alias to_a csv_row
44
73
 
74
+ # @return [Array<String>] the header of each column
45
75
  def csv_headers
46
76
  self.class.csv_headers
47
77
  end