1# frozen_string_literal: true 2 3require "forwardable" 4 5class CSV 6 # 7 # A CSV::Row is part Array and part Hash. It retains an order for the fields 8 # and allows duplicates just as an Array would, but also allows you to access 9 # fields by name just as you could if they were in a Hash. 10 # 11 # All rows returned by CSV will be constructed from this class, if header row 12 # processing is activated. 13 # 14 class Row 15 # 16 # Construct a new CSV::Row from +headers+ and +fields+, which are expected 17 # to be Arrays. If one Array is shorter than the other, it will be padded 18 # with +nil+ objects. 19 # 20 # The optional +header_row+ parameter can be set to +true+ to indicate, via 21 # CSV::Row.header_row?() and CSV::Row.field_row?(), that this is a header 22 # row. Otherwise, the row is assumes to be a field row. 23 # 24 # A CSV::Row object supports the following Array methods through delegation: 25 # 26 # * empty?() 27 # * length() 28 # * size() 29 # 30 def initialize(headers, fields, header_row = false) 31 @header_row = header_row 32 headers.each { |h| h.freeze if h.is_a? String } 33 34 # handle extra headers or fields 35 @row = if headers.size >= fields.size 36 headers.zip(fields) 37 else 38 fields.zip(headers).each(&:reverse!) 39 end 40 end 41 42 # Internal data format used to compare equality. 43 attr_reader :row 44 protected :row 45 46 ### Array Delegation ### 47 48 extend Forwardable 49 def_delegators :@row, :empty?, :length, :size 50 51 def initialize_copy(other) 52 super 53 @row = @row.dup 54 end 55 56 # Returns +true+ if this is a header row. 57 def header_row? 58 @header_row 59 end 60 61 # Returns +true+ if this is a field row. 62 def field_row? 63 not header_row? 64 end 65 66 # Returns the headers of this row. 67 def headers 68 @row.map(&:first) 69 end 70 71 # 72 # :call-seq: 73 # field( header ) 74 # field( header, offset ) 75 # field( index ) 76 # 77 # This method will return the field value by +header+ or +index+. If a field 78 # is not found, +nil+ is returned. 79 # 80 # When provided, +offset+ ensures that a header match occurs on or later 81 # than the +offset+ index. You can use this to find duplicate headers, 82 # without resorting to hard-coding exact indices. 83 # 84 def field(header_or_index, minimum_index = 0) 85 # locate the pair 86 finder = (header_or_index.is_a?(Integer) || header_or_index.is_a?(Range)) ? :[] : :assoc 87 pair = @row[minimum_index..-1].send(finder, header_or_index) 88 89 # return the field if we have a pair 90 if pair.nil? 91 nil 92 else 93 header_or_index.is_a?(Range) ? pair.map(&:last) : pair.last 94 end 95 end 96 alias_method :[], :field 97 98 # 99 # :call-seq: 100 # fetch( header ) 101 # fetch( header ) { |row| ... } 102 # fetch( header, default ) 103 # 104 # This method will fetch the field value by +header+. It has the same 105 # behavior as Hash#fetch: if there is a field with the given +header+, its 106 # value is returned. Otherwise, if a block is given, it is yielded the 107 # +header+ and its result is returned; if a +default+ is given as the 108 # second argument, it is returned; otherwise a KeyError is raised. 109 # 110 def fetch(header, *varargs) 111 raise ArgumentError, "Too many arguments" if varargs.length > 1 112 pair = @row.assoc(header) 113 if pair 114 pair.last 115 else 116 if block_given? 117 yield header 118 elsif varargs.empty? 119 raise KeyError, "key not found: #{header}" 120 else 121 varargs.first 122 end 123 end 124 end 125 126 # Returns +true+ if there is a field with the given +header+. 127 def has_key?(header) 128 !!@row.assoc(header) 129 end 130 alias_method :include?, :has_key? 131 alias_method :key?, :has_key? 132 alias_method :member?, :has_key? 133 alias_method :header?, :has_key? 134 135 # 136 # :call-seq: 137 # []=( header, value ) 138 # []=( header, offset, value ) 139 # []=( index, value ) 140 # 141 # Looks up the field by the semantics described in CSV::Row.field() and 142 # assigns the +value+. 143 # 144 # Assigning past the end of the row with an index will set all pairs between 145 # to <tt>[nil, nil]</tt>. Assigning to an unused header appends the new 146 # pair. 147 # 148 def []=(*args) 149 value = args.pop 150 151 if args.first.is_a? Integer 152 if @row[args.first].nil? # extending past the end with index 153 @row[args.first] = [nil, value] 154 @row.map! { |pair| pair.nil? ? [nil, nil] : pair } 155 else # normal index assignment 156 @row[args.first][1] = value 157 end 158 else 159 index = index(*args) 160 if index.nil? # appending a field 161 self << [args.first, value] 162 else # normal header assignment 163 @row[index][1] = value 164 end 165 end 166 end 167 168 # 169 # :call-seq: 170 # <<( field ) 171 # <<( header_and_field_array ) 172 # <<( header_and_field_hash ) 173 # 174 # If a two-element Array is provided, it is assumed to be a header and field 175 # and the pair is appended. A Hash works the same way with the key being 176 # the header and the value being the field. Anything else is assumed to be 177 # a lone field which is appended with a +nil+ header. 178 # 179 # This method returns the row for chaining. 180 # 181 def <<(arg) 182 if arg.is_a?(Array) and arg.size == 2 # appending a header and name 183 @row << arg 184 elsif arg.is_a?(Hash) # append header and name pairs 185 arg.each { |pair| @row << pair } 186 else # append field value 187 @row << [nil, arg] 188 end 189 190 self # for chaining 191 end 192 193 # 194 # A shortcut for appending multiple fields. Equivalent to: 195 # 196 # args.each { |arg| csv_row << arg } 197 # 198 # This method returns the row for chaining. 199 # 200 def push(*args) 201 args.each { |arg| self << arg } 202 203 self # for chaining 204 end 205 206 # 207 # :call-seq: 208 # delete( header ) 209 # delete( header, offset ) 210 # delete( index ) 211 # 212 # Used to remove a pair from the row by +header+ or +index+. The pair is 213 # located as described in CSV::Row.field(). The deleted pair is returned, 214 # or +nil+ if a pair could not be found. 215 # 216 def delete(header_or_index, minimum_index = 0) 217 if header_or_index.is_a? Integer # by index 218 @row.delete_at(header_or_index) 219 elsif i = index(header_or_index, minimum_index) # by header 220 @row.delete_at(i) 221 else 222 [ ] 223 end 224 end 225 226 # 227 # The provided +block+ is passed a header and field for each pair in the row 228 # and expected to return +true+ or +false+, depending on whether the pair 229 # should be deleted. 230 # 231 # This method returns the row for chaining. 232 # 233 # If no block is given, an Enumerator is returned. 234 # 235 def delete_if(&block) 236 return enum_for(__method__) { size } unless block_given? 237 238 @row.delete_if(&block) 239 240 self # for chaining 241 end 242 243 # 244 # This method accepts any number of arguments which can be headers, indices, 245 # Ranges of either, or two-element Arrays containing a header and offset. 246 # Each argument will be replaced with a field lookup as described in 247 # CSV::Row.field(). 248 # 249 # If called with no arguments, all fields are returned. 250 # 251 def fields(*headers_and_or_indices) 252 if headers_and_or_indices.empty? # return all fields--no arguments 253 @row.map(&:last) 254 else # or work like values_at() 255 all = [] 256 headers_and_or_indices.each do |h_or_i| 257 if h_or_i.is_a? Range 258 index_begin = h_or_i.begin.is_a?(Integer) ? h_or_i.begin : 259 index(h_or_i.begin) 260 index_end = h_or_i.end.is_a?(Integer) ? h_or_i.end : 261 index(h_or_i.end) 262 new_range = h_or_i.exclude_end? ? (index_begin...index_end) : 263 (index_begin..index_end) 264 all.concat(fields.values_at(new_range)) 265 else 266 all << field(*Array(h_or_i)) 267 end 268 end 269 return all 270 end 271 end 272 alias_method :values_at, :fields 273 274 # 275 # :call-seq: 276 # index( header ) 277 # index( header, offset ) 278 # 279 # This method will return the index of a field with the provided +header+. 280 # The +offset+ can be used to locate duplicate header names, as described in 281 # CSV::Row.field(). 282 # 283 def index(header, minimum_index = 0) 284 # find the pair 285 index = headers[minimum_index..-1].index(header) 286 # return the index at the right offset, if we found one 287 index.nil? ? nil : index + minimum_index 288 end 289 290 # 291 # Returns +true+ if +data+ matches a field in this row, and +false+ 292 # otherwise. 293 # 294 def field?(data) 295 fields.include? data 296 end 297 298 include Enumerable 299 300 # 301 # Yields each pair of the row as header and field tuples (much like 302 # iterating over a Hash). This method returns the row for chaining. 303 # 304 # If no block is given, an Enumerator is returned. 305 # 306 # Support for Enumerable. 307 # 308 def each(&block) 309 return enum_for(__method__) { size } unless block_given? 310 311 @row.each(&block) 312 313 self # for chaining 314 end 315 316 alias_method :each_pair, :each 317 318 # 319 # Returns +true+ if this row contains the same headers and fields in the 320 # same order as +other+. 321 # 322 def ==(other) 323 return @row == other.row if other.is_a? CSV::Row 324 @row == other 325 end 326 327 # 328 # Collapses the row into a simple Hash. Be warned that this discards field 329 # order and clobbers duplicate fields. 330 # 331 def to_h 332 hash = {} 333 each do |key, _value| 334 hash[key] = self[key] unless hash.key?(key) 335 end 336 hash 337 end 338 alias_method :to_hash, :to_h 339 340 alias_method :to_ary, :to_a 341 342 # 343 # Returns the row as a CSV String. Headers are not used. Equivalent to: 344 # 345 # csv_row.fields.to_csv( options ) 346 # 347 def to_csv(**options) 348 fields.to_csv(options) 349 end 350 alias_method :to_s, :to_csv 351 352 # 353 # Extracts the nested value specified by the sequence of +index+ or +header+ objects by calling dig at each step, 354 # returning nil if any intermediate step is nil. 355 # 356 def dig(index_or_header, *indexes) 357 value = field(index_or_header) 358 if value.nil? 359 nil 360 elsif indexes.empty? 361 value 362 else 363 unless value.respond_to?(:dig) 364 raise TypeError, "#{value.class} does not have \#dig method" 365 end 366 value.dig(*indexes) 367 end 368 end 369 370 # A summary of fields, by header, in an ASCII compatible String. 371 def inspect 372 str = ["#<", self.class.to_s] 373 each do |header, field| 374 str << " " << (header.is_a?(Symbol) ? header.to_s : header.inspect) << 375 ":" << field.inspect 376 end 377 str << ">" 378 begin 379 str.join('') 380 rescue # any encoding error 381 str.map do |s| 382 e = Encoding::Converter.asciicompat_encoding(s.encoding) 383 e ? s.encode(e) : s.force_encoding("ASCII-8BIT") 384 end.join('') 385 end 386 end 387 end 388end 389