vfcsv 1.0.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.
data/lib/vfcsv/row.rb ADDED
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ class VFCSV
4
+ # A CSV::Row-compatible class representing a single row of CSV data.
5
+ # Supports both header-based (Hash-like) and index-based (Array-like) access.
6
+ class Row
7
+ include Enumerable
8
+
9
+ attr_reader :row
10
+
11
+ # Create a new Row
12
+ # @param headers [Array] Column headers
13
+ # @param fields [Array] Field values
14
+ # @param header_row [Boolean] Whether this is a header row (default: false)
15
+ def initialize(headers, fields, header_row: false)
16
+ @row = headers.zip(fields).map { |pair| pair }
17
+ @header_row = header_row
18
+ end
19
+
20
+ # Get headers
21
+ # @return [Array] Column headers
22
+ def headers
23
+ @row.map(&:first)
24
+ end
25
+
26
+ # Get field values
27
+ # @return [Array] Field values
28
+ def fields
29
+ @row.map(&:last)
30
+ end
31
+ alias_method :values, :fields
32
+
33
+ # Access a field by header name or index
34
+ # @param header_or_index [String, Integer] Header name or column index
35
+ # @param minimum_index [Integer] For duplicate headers, start searching from this index
36
+ # @return [String, nil] Field value
37
+ def [](header_or_index, minimum_index = 0)
38
+ if header_or_index.is_a?(Integer)
39
+ pair = @row[header_or_index]
40
+ pair&.last
41
+ else
42
+ # Search by header name
43
+ @row.each_with_index do |(header, value), index|
44
+ next if index < minimum_index
45
+ return value if header == header_or_index
46
+ end
47
+ nil
48
+ end
49
+ end
50
+ alias_method :field, :[]
51
+
52
+ # Set a field by header name or index
53
+ # @param header_or_index [String, Integer] Header name or column index
54
+ # @param value [Object] New value
55
+ def []=(header_or_index, value)
56
+ if header_or_index.is_a?(Integer)
57
+ if @row[header_or_index]
58
+ @row[header_or_index][1] = value
59
+ end
60
+ else
61
+ @row.each do |pair|
62
+ if pair[0] == header_or_index
63
+ pair[1] = value
64
+ return value
65
+ end
66
+ end
67
+ # Header not found, add new pair
68
+ @row << [header_or_index, value]
69
+ end
70
+ value
71
+ end
72
+
73
+ # Append a field
74
+ # @param arg [Array] [header, value] pair
75
+ # @return [self]
76
+ def <<(arg)
77
+ if arg.is_a?(Array) && arg.size == 2
78
+ @row << arg.dup
79
+ else
80
+ @row << [nil, arg]
81
+ end
82
+ self
83
+ end
84
+
85
+ # Push a header and value
86
+ # @param header [String] Header name
87
+ # @param value [Object] Field value
88
+ # @return [self]
89
+ def push(header, value)
90
+ @row << [header, value]
91
+ self
92
+ end
93
+
94
+ # Delete a field by header or index
95
+ # @param header_or_index [String, Integer] Header name or index
96
+ # @return [Array, nil] Deleted [header, value] pair
97
+ def delete(header_or_index)
98
+ if header_or_index.is_a?(Integer)
99
+ @row.delete_at(header_or_index)
100
+ else
101
+ index = @row.index { |pair| pair[0] == header_or_index }
102
+ index ? @row.delete_at(index) : nil
103
+ end
104
+ end
105
+
106
+ # Delete fields matching a condition
107
+ # @yield [header, value] Block that returns true for fields to delete
108
+ # @return [self]
109
+ def delete_if(&block)
110
+ @row.delete_if { |pair| block.call(*pair) }
111
+ self
112
+ end
113
+
114
+ # Iterate over header/value pairs
115
+ # @yield [header, value]
116
+ # @return [Enumerator] if no block given
117
+ def each(&block)
118
+ return to_enum(__method__) unless block_given?
119
+ @row.each { |pair| block.call(*pair) }
120
+ self
121
+ end
122
+ alias_method :each_pair, :each
123
+
124
+ # Check if row is empty
125
+ # @return [Boolean]
126
+ def empty?
127
+ @row.empty?
128
+ end
129
+
130
+ # Get number of fields
131
+ # @return [Integer]
132
+ def size
133
+ @row.size
134
+ end
135
+ alias_method :length, :size
136
+
137
+ # Fetch a field with optional default
138
+ # @param header_or_index [String, Integer] Header or index
139
+ # @param default [Object] Default value if not found
140
+ # @yield Block to call if not found
141
+ # @return [Object] Field value or default
142
+ def fetch(header_or_index, *args, &block)
143
+ value = self[header_or_index]
144
+ return value unless value.nil?
145
+
146
+ # Check if header actually exists with nil value
147
+ found = if header_or_index.is_a?(Integer)
148
+ header_or_index >= 0 && header_or_index < @row.size
149
+ else
150
+ @row.any? { |pair| pair[0] == header_or_index }
151
+ end
152
+
153
+ return value if found
154
+
155
+ if block_given?
156
+ block.call(header_or_index)
157
+ elsif args.size > 0
158
+ args[0]
159
+ else
160
+ raise KeyError, "key not found: #{header_or_index.inspect}"
161
+ end
162
+ end
163
+
164
+ # Check if a value exists in the fields
165
+ # @param value [Object] Value to find
166
+ # @return [Boolean]
167
+ def field?(value)
168
+ fields.include?(value)
169
+ end
170
+ alias_method :include?, :field?
171
+ alias_method :member?, :field?
172
+
173
+ # Check if a header exists
174
+ # @param header [String] Header to find
175
+ # @return [Boolean]
176
+ def header?(header)
177
+ headers.include?(header)
178
+ end
179
+ alias_method :has_key?, :header?
180
+ alias_method :key?, :header?
181
+
182
+ # Find index of a header or value
183
+ # @param header [String] Header to find
184
+ # @param minimum_index [Integer] Start index
185
+ # @return [Integer, nil]
186
+ def index(header, minimum_index = 0)
187
+ @row.each_with_index do |(h, _v), i|
188
+ next if i < minimum_index
189
+ return i if h == header
190
+ end
191
+ nil
192
+ end
193
+
194
+ # Dig into nested data
195
+ # @param index_or_header [Integer, String] First key
196
+ # @param identifiers [Array] Additional keys for nested access
197
+ # @return [Object]
198
+ def dig(index_or_header, *identifiers)
199
+ value = self[index_or_header]
200
+ return nil if value.nil?
201
+ return value if identifiers.empty?
202
+
203
+ if value.respond_to?(:dig)
204
+ value.dig(*identifiers)
205
+ else
206
+ nil
207
+ end
208
+ end
209
+
210
+ # Get values at specified indices or headers
211
+ # @param indices_or_headers [Array] List of indices or headers
212
+ # @return [Array] Values at specified positions
213
+ def values_at(*indices_or_headers)
214
+ indices_or_headers.map { |i| self[i] }
215
+ end
216
+
217
+ # Check if this is a header row
218
+ # @return [Boolean]
219
+ def header_row?
220
+ @header_row
221
+ end
222
+
223
+ # Check if this is a field (data) row
224
+ # @return [Boolean]
225
+ def field_row?
226
+ !@header_row
227
+ end
228
+
229
+ # Compare with another row
230
+ # @param other [Row] Other row
231
+ # @return [Boolean]
232
+ def ==(other)
233
+ return false unless other.is_a?(Row)
234
+ @row == other.row
235
+ end
236
+
237
+ # Convert to hash
238
+ # @return [Hash]
239
+ def to_h
240
+ result = {}
241
+ @row.each { |header, value| result[header] = value }
242
+ result
243
+ end
244
+ alias_method :to_hash, :to_h
245
+
246
+ # Convert to array of [header, value] pairs
247
+ # @return [Array<Array>]
248
+ def to_a
249
+ @row.map(&:dup)
250
+ end
251
+
252
+ # Allow implicit conversion to array (for splat operations)
253
+ # @return [Array] Field values only
254
+ def to_ary
255
+ fields
256
+ end
257
+
258
+ # Convert to CSV string
259
+ # @return [String]
260
+ def to_csv(**options)
261
+ col_sep = options[:col_sep] || ","
262
+ quote_char = options[:quote_char] || '"'
263
+ row_sep = options[:row_sep]
264
+ row_sep = "\n" if row_sep.nil? || row_sep == :auto
265
+
266
+ field_strings = fields.map do |field|
267
+ field_str = field.to_s
268
+ if needs_quoting?(field_str, col_sep, quote_char)
269
+ quote_field(field_str, quote_char)
270
+ else
271
+ field_str
272
+ end
273
+ end
274
+
275
+ field_strings.join(col_sep) + row_sep
276
+ end
277
+ alias_method :to_s, :to_csv
278
+
279
+ # Inspection string
280
+ # @return [String]
281
+ def inspect
282
+ "#<#{self.class} #{to_h.inspect}>"
283
+ end
284
+
285
+ private
286
+
287
+ def needs_quoting?(str, col_sep, quote_char)
288
+ str.include?(col_sep) || str.include?(quote_char) || str.include?("\n") || str.include?("\r")
289
+ end
290
+
291
+ def quote_field(str, quote_char)
292
+ escaped = str.gsub(quote_char, quote_char + quote_char)
293
+ "#{quote_char}#{escaped}#{quote_char}"
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ class VFCSV
4
+ # A CSV::Table-compatible class representing a collection of CSV rows.
5
+ # Supports multiple access modes: :row, :col, and :col_or_row (default).
6
+ class Table
7
+ include Enumerable
8
+
9
+ attr_reader :table, :mode
10
+
11
+ # Create a new Table
12
+ # @param rows [Array<Row>] Array of Row objects
13
+ # @param headers [Array] Column headers (optional, derived from first row if not provided)
14
+ def initialize(rows = [], headers: nil)
15
+ @table = rows
16
+ @headers = headers || (rows.first&.headers || [])
17
+ @mode = :col_or_row
18
+ end
19
+
20
+ # Get column headers
21
+ # @return [Array]
22
+ def headers
23
+ @headers.dup
24
+ end
25
+
26
+ # Access rows or columns depending on mode
27
+ # @param index_or_header [Integer, String] Row index or column header
28
+ # @return [Row, Array] Row object or array of column values
29
+ def [](index_or_header)
30
+ case @mode
31
+ when :row
32
+ @table[index_or_header]
33
+ when :col
34
+ column_values(index_or_header)
35
+ when :col_or_row
36
+ if index_or_header.is_a?(Integer)
37
+ @table[index_or_header]
38
+ else
39
+ column_values(index_or_header)
40
+ end
41
+ end
42
+ end
43
+
44
+ # Set row or column value depending on mode
45
+ # @param index_or_header [Integer, String] Row index or column header
46
+ # @param value [Row, Array] New row or column values
47
+ def []=(index_or_header, value)
48
+ case @mode
49
+ when :row
50
+ @table[index_or_header] = value
51
+ when :col
52
+ set_column(index_or_header, value)
53
+ when :col_or_row
54
+ if index_or_header.is_a?(Integer)
55
+ @table[index_or_header] = value
56
+ else
57
+ set_column(index_or_header, value)
58
+ end
59
+ end
60
+ end
61
+
62
+ # Append a row
63
+ # @param row [Row, Array] Row to append
64
+ # @return [self]
65
+ def <<(row)
66
+ if row.is_a?(Row)
67
+ @table << row
68
+ else
69
+ @table << Row.new(@headers, row)
70
+ end
71
+ self
72
+ end
73
+ alias_method :push, :<<
74
+
75
+ # Delete a row or column
76
+ # @param index_or_header [Integer, String] Row index or column header
77
+ # @return [Row, Array, nil] Deleted row or column values
78
+ def delete(index_or_header)
79
+ case @mode
80
+ when :row
81
+ @table.delete_at(index_or_header)
82
+ when :col
83
+ delete_column(index_or_header)
84
+ when :col_or_row
85
+ if index_or_header.is_a?(Integer)
86
+ @table.delete_at(index_or_header)
87
+ else
88
+ delete_column(index_or_header)
89
+ end
90
+ end
91
+ end
92
+
93
+ # Delete rows/columns matching condition
94
+ # @yield [row_or_col] Block that returns true for items to delete
95
+ # @return [self]
96
+ def delete_if(&block)
97
+ @table.delete_if(&block)
98
+ self
99
+ end
100
+
101
+ # Iterate over rows
102
+ # @yield [Row]
103
+ # @return [Enumerator] if no block given
104
+ def each(&block)
105
+ return to_enum(__method__) unless block_given?
106
+ @table.each(&block)
107
+ self
108
+ end
109
+
110
+ # Check if table is empty
111
+ # @return [Boolean]
112
+ def empty?
113
+ @table.empty?
114
+ end
115
+
116
+ # Get number of rows
117
+ # @return [Integer]
118
+ def size
119
+ @table.size
120
+ end
121
+ alias_method :length, :size
122
+
123
+ # Dig into nested data
124
+ # @param index [Integer] Row index
125
+ # @param args [Array] Additional keys
126
+ # @return [Object]
127
+ def dig(index, *args)
128
+ row = @table[index]
129
+ return nil if row.nil?
130
+ return row if args.empty?
131
+ row.dig(*args)
132
+ end
133
+
134
+ # Get rows at specified indices
135
+ # @param indices [Array<Integer>] Row indices
136
+ # @return [Array<Row>]
137
+ def values_at(*indices)
138
+ indices.map { |i| @table[i] }
139
+ end
140
+
141
+ # Switch to row access mode (returns new table with mode set)
142
+ # @return [Table]
143
+ def by_row
144
+ dup_with_mode(:row)
145
+ end
146
+
147
+ # Switch to row access mode (mutates self)
148
+ # @return [self]
149
+ def by_row!
150
+ @mode = :row
151
+ self
152
+ end
153
+
154
+ # Switch to column access mode (returns new table with mode set)
155
+ # @return [Table]
156
+ def by_col
157
+ dup_with_mode(:col)
158
+ end
159
+
160
+ # Switch to column access mode (mutates self)
161
+ # @return [self]
162
+ def by_col!
163
+ @mode = :col
164
+ self
165
+ end
166
+
167
+ # Switch to column-or-row access mode (returns new table with mode set)
168
+ # @return [Table]
169
+ def by_col_or_row
170
+ dup_with_mode(:col_or_row)
171
+ end
172
+
173
+ # Switch to column-or-row access mode (mutates self)
174
+ # @return [self]
175
+ def by_col_or_row!
176
+ @mode = :col_or_row
177
+ self
178
+ end
179
+
180
+ # Compare with another table
181
+ # @param other [Table] Other table
182
+ # @return [Boolean]
183
+ def ==(other)
184
+ return false unless other.is_a?(Table)
185
+ @table == other.table && @headers == other.headers
186
+ end
187
+
188
+ # Convert to array (includes headers as first row)
189
+ # @return [Array<Array>]
190
+ def to_a
191
+ [@headers] + @table.map(&:fields)
192
+ end
193
+
194
+ # Convert to CSV string
195
+ # @param options [Hash] CSV options
196
+ # @return [String]
197
+ def to_csv(**options)
198
+ write_headers = options.fetch(:write_headers, true)
199
+ col_sep = options[:col_sep] || ","
200
+ quote_char = options[:quote_char] || '"'
201
+ row_sep = options[:row_sep]
202
+ row_sep = "\n" if row_sep.nil? || row_sep == :auto
203
+
204
+ result = +""
205
+
206
+ if write_headers
207
+ result << generate_line(@headers, col_sep, quote_char, row_sep)
208
+ end
209
+
210
+ @table.each do |row|
211
+ result << generate_line(row.fields, col_sep, quote_char, row_sep)
212
+ end
213
+
214
+ result
215
+ end
216
+ alias_method :to_s, :to_csv
217
+
218
+ # Inspection string
219
+ # @return [String]
220
+ def inspect
221
+ "#<#{self.class} mode:#{@mode} row_count:#{size}>"
222
+ end
223
+
224
+ private
225
+
226
+ def column_values(header)
227
+ @table.map { |row| row[header] }
228
+ end
229
+
230
+ def set_column(header, values)
231
+ @table.each_with_index do |row, i|
232
+ row[header] = values[i] if values[i]
233
+ end
234
+ end
235
+
236
+ def delete_column(header)
237
+ values = column_values(header)
238
+ @table.each { |row| row.delete(header) }
239
+ @headers.delete(header)
240
+ values
241
+ end
242
+
243
+ def dup_with_mode(new_mode)
244
+ new_table = self.class.new(@table.dup, headers: @headers.dup)
245
+ new_table.instance_variable_set(:@mode, new_mode)
246
+ new_table
247
+ end
248
+
249
+ def generate_line(row, col_sep, quote_char, row_sep)
250
+ fields = row.map do |field|
251
+ field_str = field.to_s
252
+ if needs_quoting?(field_str, col_sep, quote_char)
253
+ quote_field(field_str, quote_char)
254
+ else
255
+ field_str
256
+ end
257
+ end
258
+ fields.join(col_sep) + row_sep
259
+ end
260
+
261
+ def needs_quoting?(str, col_sep, quote_char)
262
+ str.include?(col_sep) || str.include?(quote_char) || str.include?("\n") || str.include?("\r")
263
+ end
264
+
265
+ def quote_field(str, quote_char)
266
+ escaped = str.gsub(quote_char, quote_char + quote_char)
267
+ "#{quote_char}#{escaped}#{quote_char}"
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class VFCSV
4
+ VERSION = "1.0.0"
5
+ end