module SmarterCSV

Constants

VERSION

Public Class Methods

process(input, options={}) { |chunk| ... } click to toggle source
# File lib/smarter_csv/smarter_csv.rb, line 9
  def SmarterCSV.process(input, options={}, &block)   # first parameter: filename or input object with readline method
    default_options = {:col_sep => ',' , :row_sep => $/ , :quote_char => '"', :force_simple_split => false , :verbose => false ,
      :remove_empty_values => true, :remove_zero_values => false , :remove_values_matching => nil , :remove_empty_hashes => true , :strip_whitespace => true,
      :convert_values_to_numeric => true, :strip_chars_from_headers => nil , :user_provided_headers => nil , :headers_in_file => true,
      :comment_regexp => /\A#/, :chunk_size => nil , :key_mapping_hash => nil , :downcase_header => true, :strings_as_keys => false, :file_encoding => 'utf-8',
      :remove_unmapped_keys => false, :keep_original_headers => false, :value_converters => nil, :skip_lines => nil, :force_utf8 => false, :invalid_byte_sequence => '',
      :auto_row_sep_chars => 500, :required_headers => nil
    }
    options = default_options.merge(options)
    options[:invalid_byte_sequence] = '' if options[:invalid_byte_sequence].nil?
    csv_options = options.select{|k,v| [:col_sep, :row_sep, :quote_char].include?(k)} # options.slice(:col_sep, :row_sep, :quote_char)
    headerA = []
    result = []
    old_row_sep = $/
    file_line_count = 0
    csv_line_count = 0
    has_rails = !! defined?(Rails)
    begin
      f = input.respond_to?(:readline) ? input : File.open(input, "r:#{options[:file_encoding]}")

      if (options[:force_utf8] || options[:file_encoding] =~ /utf-8/i) && ( f.respond_to?(:external_encoding) && f.external_encoding != Encoding.find('UTF-8') || f.respond_to?(:encoding) && f.encoding != Encoding.find('UTF-8') )
        puts 'WARNING: you are trying to process UTF-8 input, but did not open the input with "b:utf-8" option. See README file "NOTES about File Encodings".'
      end

      if options[:row_sep] == :auto
        options[:row_sep] = line_ending = SmarterCSV.guess_line_ending( f, options )
        f.rewind
      end
      $/ = options[:row_sep]

      if options[:skip_lines].to_i > 0
        options[:skip_lines].to_i.times{f.readline}
      end

      if options[:headers_in_file]        # extract the header line
        # process the header line in the CSV file..
        # the first line of a CSV file contains the header .. it might be commented out, so we need to read it anyhow
        header = f.readline
        header = header.force_encoding('utf-8').encode('utf-8', invalid: :replace, undef: :replace, replace: options[:invalid_byte_sequence]) if options[:force_utf8] || options[:file_encoding] !~ /utf-8/i
        header = header.sub(options[:comment_regexp],'').chomp(options[:row_sep])

        file_line_count += 1
        csv_line_count += 1
        header = header.gsub(options[:strip_chars_from_headers], '') if options[:strip_chars_from_headers]

        if (header =~ %r{#{options[:quote_char]}}) and (! options[:force_simple_split])
          file_headerA = begin
            CSV.parse( header, **csv_options ).flatten.collect!{|x| x.nil? ? '' : x} # to deal with nil values from CSV.parse
          rescue CSV::MalformedCSVError => e
            raise $!, "#{$!} [SmarterCSV: csv line #{csv_line_count}]", $!.backtrace
          end
        else
          file_headerA =  header.split(options[:col_sep])
        end
        file_headerA.map!{|x| x.gsub(%r/#{options[:quote_char]}/,'') }
        file_headerA.map!{|x| x.strip}  if options[:strip_whitespace]
        unless options[:keep_original_headers]
          file_headerA.map!{|x| x.gsub(/\s+|-+/,'_')}
          file_headerA.map!{|x| x.downcase }   if options[:downcase_header]
        end

        file_header_size = file_headerA.size
      else
        raise SmarterCSV::IncorrectOption , "ERROR: If :headers_in_file is set to false, you have to provide :user_provided_headers" if options[:user_provided_headers].nil?
      end
      if options[:user_provided_headers] && options[:user_provided_headers].class == Array && ! options[:user_provided_headers].empty?
        # use user-provided headers
        headerA = options[:user_provided_headers]
        if defined?(file_header_size) && ! file_header_size.nil?
          if headerA.size != file_header_size
            raise SmarterCSV::HeaderSizeMismatch , "ERROR: :user_provided_headers defines #{headerA.size} headers !=  CSV-file #{input} has #{file_header_size} headers"
          else
            # we could print out the mapping of file_headerA to headerA here
          end
        end
      else
        headerA = file_headerA
      end
      headerA.map!{|x| x.to_sym } unless options[:strings_as_keys] || options[:keep_original_headers]

      unless options[:user_provided_headers] # wouldn't make sense to re-map user provided headers
        key_mappingH = options[:key_mapping]

        # do some key mapping on the keys in the file header
        #   if you want to completely delete a key, then map it to nil or to ''
        if ! key_mappingH.nil? && key_mappingH.class == Hash && key_mappingH.keys.size > 0
          headerA.map!{|x| key_mappingH.has_key?(x) ? (key_mappingH[x].nil? ? nil : key_mappingH[x].to_sym) : (options[:remove_unmapped_keys] ? nil : x)}
        end
      end

      # header_validations
      duplicate_headers = []
      headerA.compact.each do |k|
        duplicate_headers << k if headerA.select{|x| x == k}.size > 1
      end
      raise SmarterCSV::DuplicateHeaders , "ERROR: duplicate headers: #{duplicate_headers.join(',')}" unless duplicate_headers.empty?

      if options[:required_headers] && options[:required_headers].is_a?(Array)
        missing_headers = []
        options[:required_headers].each do |k|
          missing_headers << k unless headerA.include?(k)
        end
        raise SmarterCSV::MissingHeaders , "ERROR: missing headers: #{missing_headers.join(',')}" unless missing_headers.empty?
      end

      # in case we use chunking.. we'll need to set it up..
      if ! options[:chunk_size].nil? && options[:chunk_size].to_i > 0
        use_chunks = true
        chunk_size = options[:chunk_size].to_i
        chunk_count = 0
        chunk = []
      else
        use_chunks = false
      end

      # now on to processing all the rest of the lines in the CSV file:
      while ! f.eof?    # we can't use f.readlines() here, because this would read the whole file into memory at once, and eof => true
        line = f.readline  # read one line.. this uses the input_record_separator $/ which we set previously!

        # replace invalid byte sequence in UTF-8 with question mark to avoid errors
        line = line.force_encoding('utf-8').encode('utf-8', invalid: :replace, undef: :replace, replace: options[:invalid_byte_sequence]) if options[:force_utf8] || options[:file_encoding] !~ /utf-8/i

        file_line_count += 1
        csv_line_count += 1
        print "processing file line %10d, csv line %10d\r" % [file_line_count, csv_line_count] if options[:verbose]
        next  if  line =~ options[:comment_regexp]  # ignore all comment lines if there are any

        # cater for the quoted csv data containing the row separator carriage return character
        # in which case the row data will be split across multiple lines (see the sample content in spec/fixtures/carriage_returns_rn.csv)
        # by detecting the existence of an uneven number of quote characters
        multiline = line.count(options[:quote_char])%2 == 1
        while line.count(options[:quote_char])%2 == 1
          next_line = f.readline
          next_line = next_line.force_encoding('utf-8').encode('utf-8', invalid: :replace, undef: :replace, replace: options[:invalid_byte_sequence]) if options[:force_utf8] || options[:file_encoding] !~ /utf-8/i
          line += next_line
          file_line_count += 1
        end
        print "\nline contains uneven number of quote chars so including content through file line %d\n" % file_line_count if options[:verbose] && multiline

        line.chomp!    # will use $/ which is set to options[:col_sep]

        if (line =~ %r{#{options[:quote_char]}}) and (! options[:force_simple_split])
          dataA = begin
            CSV.parse( line, **csv_options ).flatten.collect!{|x| x.nil? ? '' : x} # to deal with nil values from CSV.parse
          rescue CSV::MalformedCSVError => e
            raise $!, "#{$!} [SmarterCSV: csv line #{csv_line_count}]", $!.backtrace
          end
        else
          dataA =  line.split(options[:col_sep])
        end
####     dataA.map!{|x| x.gsub(%r/#{options[:quote_char]}/,'') }  # this is actually not a good idea as a default
        dataA.map!{|x| x.strip}  if options[:strip_whitespace]
        hash = Hash.zip(headerA,dataA)  # from Facets of Ruby library
        # make sure we delete any key/value pairs from the hash, which the user wanted to delete:
        # Note: Ruby < 1.9 doesn't allow empty symbol literals!
        hash.delete(nil); hash.delete('');
        if RUBY_VERSION.to_f > 1.8
          eval('hash.delete(:"")')
        end

        # remove empty values using the same regexp as used by the rails blank? method
        # which caters for double \n and \r\n characters such as "1\r\n\r\n2" whereas the original check (v =~ /^\s*$/) does not
        if options[:remove_empty_values]
          if has_rails
            hash.delete_if{|k,v| v.blank?}
          else
            hash.delete_if{|k,v| v.nil? || v !~ /[^[:space:]]/}
          end
        end

        hash.delete_if{|k,v| ! v.nil? && v =~ /^(\d+|\d+\.\d+)$/ && v.to_f == 0} if options[:remove_zero_values]   # values are typically Strings!
        hash.delete_if{|k,v| v =~ options[:remove_values_matching]} if options[:remove_values_matching]
        if options[:convert_values_to_numeric]
          hash.each do |k,v|
            # deal with the :only / :except options to :convert_values_to_numeric
            next if SmarterCSV.only_or_except_limit_execution( options, :convert_values_to_numeric , k )

            # convert if it's a numeric value:
            case v
            when /^[+-]?\d+\.\d+$/
              hash[k] = v.to_f
            when /^[+-]?\d+$/
              hash[k] = v.to_i
            end
          end
        end

        if options[:value_converters]
          hash.each do |k,v|
            converter = options[:value_converters][k]
            next unless converter
            hash[k] = converter.convert(v)
          end
        end

        next if hash.empty? if options[:remove_empty_hashes]

        if use_chunks
          chunk << hash  # append temp result to chunk

          if chunk.size >= chunk_size || f.eof?   # if chunk if full, or EOF reached
            # do something with the chunk
            if block_given?
              yield chunk  # do something with the hashes in the chunk in the block
            else
              result << chunk  # not sure yet, why anybody would want to do this without a block
            end
            chunk_count += 1
            chunk = []  # initialize for next chunk of data
          else

            # the last chunk may contain partial data, which also needs to be returned (BUG / ISSUE-18)

          end

          # while a chunk is being filled up we don't need to do anything else here

        else # no chunk handling
          if block_given?
            yield [hash]  # do something with the hash in the block (better to use chunking here)
          else
            result << hash
          end
        end
      end

      # print new line to retain last processing line message
      print "\n" if options[:verbose]

      # last chunk:
      if ! chunk.nil? && chunk.size > 0
        # do something with the chunk
        if block_given?
          yield chunk  # do something with the hashes in the chunk in the block
        else
          result << chunk  # not sure yet, why anybody would want to do this without a block
        end
        chunk_count += 1
        chunk = []  # initialize for next chunk of data
      end
    ensure
      $/ = old_row_sep   # make sure this stupid global variable is always reset to it's previous value after we're done!
      f.close if f.respond_to?(:close)
    end
    if block_given?
      return chunk_count  # when we do processing through a block we only care how many chunks we processed
    else
      return result # returns either an Array of Hashes, or an Array of Arrays of Hashes (if in chunked mode)
    end
  end

Private Class Methods

guess_line_ending( filehandle, options ) click to toggle source

limitation: this currently reads the whole file in before making a decision

# File lib/smarter_csv/smarter_csv.rb, line 275
def self.guess_line_ending( filehandle, options )
  counts = {"\n" => 0 , "\r" => 0, "\r\n" => 0}
  quoted_char = false

  # count how many of the pre-defined line-endings we find
  # ignoring those contained within quote characters
  last_char = nil
  lines = 0
  filehandle.each_char do |c|
    quoted_char = !quoted_char if c == options[:quote_char]
    next if quoted_char

    if last_char == "\r"
      if c == "\n"
        counts["\r\n"] +=  1
      else
        counts["\r"] += 1  # \r are counted after they appeared, we might
      end
    elsif c == "\n"
      counts["\n"] += 1
    end
    last_char = c
    lines += 1
    break if options[:auto_row_sep_chars] && options[:auto_row_sep_chars] > 0 && lines >= options[:auto_row_sep_chars]
  end
  counts["\r"] += 1 if last_char == "\r"
  # find the key/value pair with the largest counter:
  k,_ = counts.max_by{|_,v| v}
  return k                    # the most frequent one is it
end
only_or_except_limit_execution( options, option_name, key ) click to toggle source

acts as a road-block to limit processing when iterating over all k/v pairs of a CSV-hash:

# File lib/smarter_csv/smarter_csv.rb, line 263
def self.only_or_except_limit_execution( options, option_name, key )
  if options[option_name].is_a?(Hash)
    if options[option_name].has_key?( :except )
      return true if Array( options[ option_name ][:except] ).include?(key)
    elsif options[ option_name ].has_key?(:only)
      return true unless Array( options[ option_name ][:only] ).include?(key)
    end
  end
  return false
end