Skip to content

Instantly share code, notes, and snippets.

@rogerleite
Last active August 28, 2024 19:34
Show Gist options
  • Save rogerleite/6aae63c750bdc624da68 to your computer and use it in GitHub Desktop.
Save rogerleite/6aae63c750bdc624da68 to your computer and use it in GitHub Desktop.
Ruby and CSV examples
require "csv"
require "date"
puts CSV::HeaderConverters.keys.inspect # => [:downcase, :symbol]
# Add new header converter
CSV::HeaderConverters[:remap] = lambda do |raw_value|
raw_value = raw_value.to_sym
case raw_value
when :country
:pais
when :birthday
:dt_nascimento
else
raw_value
end
end
table_students = CSV.table('mock_data.csv', col_sep: ",", header_converters: :remap)
table_students.each do |row|
puts [row.fetch(:pais), row.fetch(:dt_nascimento)].inspect
end
puts CSV::Converters.keys.inspect # => [:integer, :float, :numeric, :date, :date_time, :all]
# Add new converter
CSV::Converters[:nil_to_empty] = lambda do |raw_value|
raw_value.nil? ? "" : raw_value
end
# Add new converter
CSV::Converters[:brazilian_date] = lambda do |raw_value|
if raw_value =~ /\d{2}\/\d{2}\/\d{4}/
Date.strptime(raw_value, "%d/%m/%Y")
else
raw_value
end
end
# Group converters
CSV::Converters[:my_custom_converters] = [:nil_to_empty, :brazilian_date]
table_students = CSV.table('mock_data.csv', col_sep: ",", converters: :my_custom_converters)
table_students.each do |row|
puts [row.fetch(:country), row.fetch(:birthday)].inspect
end
id name country birthday
1 Virginia Harvey GB 01/06/1993
2 Stephen Walker BR 11/09/1985
3 Debra Reed US 02/01/1997
4 Nicholas Lee NG 10/07/2002
5 Louis Wilson MW 12/06/1989
6 Paula Nichols TH 28/05/1996
7 Alice White 14/11/1991
8 Gregory Roberts 26/06/1999
9 Raymond Mason CU 05/01/1996
10 Gary Reed 10/12/1999

Lendo arquivo CSV com parcimônia no Ruby

Ler e escrever arquivo csv é um mal necessário de muitos sistemas, ainda mais levando em conta que esta integração será feita via Excel, em algum Windows, com quilos de texto com acentos e dados a formatar. Dado este cenário, e que ele provavelmente se repetirá no futuro, deixo aqui um post auto-ajuda para mim mesmo e provavelmente para você que está lendo. :D

Na versão 1.9.3 e superior, o Ruby incluiu a classe CSV na sua standard lib, que facilita o trabalho de ler e/ou escrever arquivos csv. Exemplos em código abaixo.

Conhecendo o CSV

O modo mais simples e direto para ler um arquivo csv, é usar o CSV.read que retorna um Array de Arrays:

require 'csv'
array_students = CSV.read('/tmp/mock_data.csv') # return an Array of Arrays
array_students.each { |row| puts row.inspect }  # => output:
# "[\"id\", \"name\", \"country\", \"birthday\"]"
# "[\"1\", \"Virginia Harvey\", \"GB\", \"01/06/1993\"]"

Dentro da classe CSV, existem mais duas classes que facilitam ainda mais o manuseio dos dados.

Caso necessite de mais requinte e sofisticação, o método CSV.table retorna uma instância de CSV::Table. Com o table, você tem acesso ao cabeçalho através do headers e acesso a cada linha do arquivo com o each, que retorna uma instância de CSV::Row.

require 'csv'
table_students = CSV.table('/tmp/mock_data.csv') # => instance of CSV::Table
puts table_students.headers.inspect # => [:id, :name, :country, :birthday]
table_students.each { |row| puts row.inspect } # => output:
# <CSV::Row id:1 name:"Virginia Harvey" country:"GB" birthday:"01/06/1993">
table_students.each { |row| puts row.fetch(:name) } # => output:
# Virginia Harvey

Tanto o read quanto o table, aceitam um hash de options como segundo argumento. Tem uma descrição detalhada na documentação do método new. Exemplo usando options:

require 'csv'
table_students = CSV.table('/tmp/mock_data2.csv', col_sep: ";", skip_blanks: true, converters: [])
table_students.each { |row| puts row.inspect }

CSV converters

CSV::HeaderConverters contém um hash de symbol e block que são usados para converter os valores do cabeçalho. Para usá-los, você deve informar qual converter deseja aplicar na opção header_converters. Acredito que o código abaixo explica melhor.

require 'csv'
puts CSV::HeaderConverters.keys.inspect # => [:downcase, :symbol]

# Add new header converter
CSV::HeaderConverters[:remap] = lambda do |raw_value|
  raw_value = raw_value.to_sym
  case raw_value
  when :country
    :pais
  when :birthday
    :dt_nascimento
  else
    raw_value
  end
end

table_students = CSV.table('mock_data.csv', col_sep: ",", header_converters: :remap)
table_students.each do |row|
  puts [row.fetch(:pais), row.fetch(:dt_nascimento)].inspect # => ["GB", "01/06/1993"]
end

No exemplo acima, criei o HeaderConverter "remap" que traduz o cabeçalho country para pais e birthday para dt_nascimento. Por padrão, o CSV disponibiliza os converters downcase e symbol, que por sinal são usados quando usamos o método table para ler csv.

CSV::Converters segue o mesmo padrão de symbol e block, a única diferença que este é usado para converter os valores da linha. Vamos ao código.

require 'csv'
require 'date'

puts CSV::Converters.keys.inspect       # => [:integer, :float, :numeric, :date, :date_time, :all]

# Add new converter
CSV::Converters[:nil_to_empty] = lambda do |raw_value|
  raw_value.nil? ? "" : raw_value
end

# Add new converter
CSV::Converters[:brazilian_date] = lambda do |raw_value|
  if raw_value =~ /\d{2}\/\d{2}\/\d{4}/
    Date.strptime(raw_value, "%d/%m/%Y")
  else
    raw_value
  end
end

# Group converters
CSV::Converters[:my_custom_converters] = [:nil_to_empty, :brazilian_date]

table_students = CSV.table('mock_data.csv', col_sep: ",", converters: :my_custom_converters)
table_students.each do |row|
  puts [row.fetch(:country), row.fetch(:birthday)].inspect # => ["GB", #<Date: 1993-06-01 ((2449140j,0s,0n),+0s,2299161j)>]
end

No exemplo acima criei dois converters. Um para trocar nil por "" e o outro que converte para Date caso o valor esteja no formato 99/99/9999.

Encoding hell com Excel

Normalmente o csv é usado como meio de integração Excel <=> Sistema. Acontece que o Excel não se dá muito bem com acentos especiais como ãõáé etc. Isto porque estamos em 2014. Acontece que quando há caracteres especiais, a única abordagem que funcionou foi exportar para Unicode text. Neste formato, o encoding do arquivo é UTF-16LE e separado por tab (\t). Este post de 2009 da Plataformatec explica com mais detalhes este jeitinho do Excel de ser com os dados. A única diferença de 2009 pra hoje, é que podemos passar o encoding como parâmetro ao ler o arquivo, e por sorte evitar o uso do iconv. Vamos ao código:

require 'csv'

table = CSV.table('mock_unicode.txt',
                  col_sep: "\t", # tab as delimiter
                  encoding: "UTF-16LE:UTF-8") # read UTF-16LE and convert to UTF-8
table.each do |row|
  puts row.inspect
end

Evitando o abuso de memória

Ao ler arquivos com read ou table, o arquivo é colocado em memória, ou seja, ao processar uma planilha de 100mb, o seu processo ruby vai pra um 100mb e pouco. Agora imagina 20 workers e cada um processando uma planilha de 100mb ou mais, facilmente o seu servidor terá um pico de consumo de memória, o no pior cenário vai dar crash no processo. Para evitar este consumo devemos usar o foreach do CSV.

require 'csv'

CSV.foreach("mock_data.csv", col_sep: ",") do |row|
  puts row.inspect
end

# =>
# ["id", "name", "country", "birthday"]
# ["1", "Virginia Harvey", "GB", "01/06/1993"]

Desta maneira a leitura é mais otimizada, pois apenas uma linha por vez é lida. O único problema é que perdemos algumas facilidades do table, como os headers e a instância do CSV::Row por linha. Tentando chegar no modelo ideal, montei uma classe que usa o foreach e mesmo assim tem os headers e os rows.

require 'csv'

class SheetReader

  attr_reader :headers

  def initialize(filepath)
    # options to read unicode text file
    # options = {
    #   col_sep: "\t",
    #   skip_blanks: true,
    #   encoding: "UTF-16LE:UTF-8",
    #   converters: []
    # }
    options = {col_sep: ",", converters: []}
    @csv_reader = CSV.foreach(filepath, options) # gets a iterator
    @headers    = convert_headers(@csv_reader.next) # read first line
  end

  # yield an instance of http://ruby-doc.org/stdlib-2.1.0/libdoc/csv/rdoc/CSV/Row.html
  def each_row(&block)
    begin
      while true
        raw_row = @csv_reader.next  # raise StopIteration in EOF
        yield CSV::Row.new(headers, raw_row)
      end
    rescue StopIteration
    end
  end

  protected

  # Internal: Convert headers to Array of symbols.
  #
  # raw_headers - Array of Strings.
  #
  # Examples
  #
  #   convert_headers(["ATIVO", "NOME COMERCIAL"])
  #   # => [:ativo, :nome_comercial]
  #
  # Returns Array of symbols.
  def convert_headers(raw_headers)
    raw_headers.compact! # removes nil values
    converter = lambda do |header|
      header_converters = CSV::HeaderConverters.values
      header_converters.inject(header) do |header, converter_proc|
        converter_proc.call(header)
      end
    end

    raw_headers.map { |header| converter.call(header) }
  end

end

reader = SheetReader.new('mock_data.csv')
reader.headers # => [:id, :name, :country, :birthday]
reader.each_row do |row|
  puts row.inspect # => #<CSV::Row id:"1" name:"Virginia Harvey" country:"GB" birthday:"01/06/1993">
end

Por último, uma observação importante: todo este código acima foi rodado no ruby 2.1.0. Espero que este mini guia de como ler arquivo csv com Ruby te ajude. Segue alguns links com mais informações:

Dúvidas, sugestões ou qualquer outra coisa. Deixe um comentário ou se preferir, mande um tweety! :D

@danilokleber
Copy link

Obrigado!

@rogerleite
Copy link
Author

❤️ @danilokleber eu que agradeço o comentário! Faz muito tempo que fiz este post, é bom saber que ainda consegue ajudar as pessoas.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment