ruby - rails csv:: row
¿Cómo fuerzo que un campo en la salida CSV de Ruby se ajuste con comillas dobles? (6)
Estoy generando una salida CSV usando el CSV incorporado de Ruby. Todo funciona bien, pero el cliente desea que el campo de nombre en la salida incluya comillas dobles para que la salida se vea como el archivo de entrada. Por ejemplo, la entrada se ve algo como esto:
1,1.1.1.1,"Firstname Lastname",more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
La salida de CSV, que es correcta, se ve así:
1,1.1.1.1,Firstname Lastname,more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
Sé que CSV está haciendo lo correcto al no citar el tercer campo por el simple hecho de que tiene espacios en blanco incrustados, y envolver el campo con comillas dobles cuando tiene la coma incrustada. Lo que me gustaría hacer, para ayudar al cliente a sentirse cálido y confuso, es decirle a CSV que siempre doble comillas en el tercer campo.
Intenté envolver el campo con comillas dobles en mi método to_a
, lo que crea un campo "Firstname Lastname"
se pasa a CSV, pero CSV se rió de mi intento y salida """Firstname Lastname"""
. Eso es lo que se debe hacer correctamente porque se escapa de las comillas dobles, por lo que no funcionó.
Luego intenté establecer CSV :force_quotes => true
en el método open
, que :force_quotes => true
comillas dobles en todos los campos como se esperaba, pero al cliente no le gustó eso, lo cual también esperaba. Entonces, eso tampoco funcionó.
He revisado los documentos de la tabla y la fila y no parece haber nada que me permita acceder al método "generar un campo de cadena", o una forma de establecer un indicador "para el campo n siempre usar comillas".
Estoy a punto de sumergirme en la fuente para ver si hay algunos ajustes súper secretos, o si hay una manera de parchar el mono CSV y doblarlo para hacer mi voluntad, pero me pregunté si alguien tenía algún conocimiento especial o se había topado con esto. antes de.
Y, sí, sé que podría rodar mi propia salida CSV, pero prefiero no reinventar ruedas bien probadas. Y, también estoy al tanto de FasterCSV; Ahora es parte de Ruby 1.9.2, que estoy usando, así que usar FasterCSV explícitamente no me ofrece nada especial. Además, no estoy usando Rails y no tengo ninguna intención de reescribirlo en Rails, así que a menos que tengas una forma linda de implementarlo usando un pequeño subconjunto de Rails, no te preocupes. Abajo voté cualquier recomendación para usar cualquiera de esas formas solo porque no te molestaste en leer hasta aquí.
CSV ha cambiado un poco en Ruby 2.1 como lo menciona @jwadsack, sin embargo, aquí hay una versión de trabajo de @ the-tin-man-man MyCSV. Poco modificado, establece los forzados_campos_campos mediante opciones.
MyCSV.generate(forced_quote_fields: [1]) do |_csv|...
El código modificado
require ''csv''
class MyCSV < CSV
def <<(row)
# make sure headers have been assigned
if header_row? and [Array, String].include? @use_headers.class
parse_headers # won''t read data for Array or String
self << @headers if @write_headers
end
# handle CSV::Row objects and Hashes
row = case row
when self.class::Row then row.fields
when Hash then @headers.map { |header| row[header] }
else row
end
@headers = row if header_row?
@lineno += 1
output = row.map.with_index(&@quote).join(@col_sep) + @row_sep # quote and separate
if @io.is_a?(StringIO) and
output.encoding != (encoding = raw_encoding)
if @force_encoding
output = output.encode(encoding)
elsif (compatible_encoding = Encoding.compatible?(@io.string, output))
@io.set_encoding(compatible_encoding)
@io.seek(0, IO::SEEK_END)
end
end
@io << output
self # for chaining
end
def init_separators(options)
# store the selected separators
@col_sep = options.delete(:col_sep).to_s.encode(@encoding)
@row_sep = options.delete(:row_sep) # encode after resolving :auto
@quote_char = options.delete(:quote_char).to_s.encode(@encoding)
@forced_quote_fields = options.delete(:forced_quote_fields) || []
if @quote_char.length != 1
raise ArgumentError, ":quote_char has to be a single character String"
end
#
# automatically discover row separator when requested
# (not fully encoding safe)
#
if @row_sep == :auto
if [ARGF, STDIN, STDOUT, STDERR].include?(@io) or
(defined?(Zlib) and @io.class == Zlib::GzipWriter)
@row_sep = $INPUT_RECORD_SEPARATOR
else
begin
#
# remember where we were (pos() will raise an exception if @io is pipe
# or not opened for reading)
#
saved_pos = @io.pos
while @row_sep == :auto
#
# if we run out of data, it''s probably a single line
# (ensure will set default value)
#
break unless sample = @io.gets(nil, 1024)
# extend sample if we''re unsure of the line ending
if sample.end_with? encode_str("/r")
sample << (@io.gets(nil, 1) || "")
end
# try to find a standard separator
if sample =~ encode_re("/r/n?|/n")
@row_sep = $&
break
end
end
# tricky seek() clone to work around GzipReader''s lack of seek()
@io.rewind
# reset back to the remembered position
while saved_pos > 1024 # avoid loading a lot of data into memory
@io.read(1024)
saved_pos -= 1024
end
@io.read(saved_pos) if saved_pos.nonzero?
rescue IOError # not opened for reading
# do nothing: ensure will set default
rescue NoMethodError # Zlib::GzipWriter doesn''t have some IO methods
# do nothing: ensure will set default
rescue SystemCallError # pipe
# do nothing: ensure will set default
ensure
#
# set default if we failed to detect
# (stream not opened for reading, a pipe, or a single line of data)
#
@row_sep = $INPUT_RECORD_SEPARATOR if @row_sep == :auto
end
end
end
@row_sep = @row_sep.to_s.encode(@encoding)
# establish quoting rules
@force_quotes = options.delete(:force_quotes)
do_quote = lambda do |field|
field = String(field)
encoded_quote = @quote_char.encode(field.encoding)
encoded_quote +
field.gsub(encoded_quote, encoded_quote * 2) +
encoded_quote
end
quotable_chars = encode_str("/r/n", @col_sep, @quote_char)
@quote = if @force_quotes
do_quote
else
lambda do |field, index|
if field.nil? # represent +nil+ fields as empty unquoted fields
""
else
field = String(field) # Stringify fields
# represent empty fields as empty quoted fields
if field.empty? or
field.count(quotable_chars).nonzero? or
@forced_quote_fields.include?(index)
do_quote.call(field)
else
field # unquoted field
end
end
end
end
end
end
Bueno, hay una forma de hacerlo pero no estaba tan limpia como esperaba que el código CSV pudiera permitir.
Tuve que subclasificar CSV, luego anular CSV::Row.<<=
method y agregar otro método forced_quote_fields=
para hacer posible definir los campos en los que quiero forzar el presupuesto, además de extraer dos lambdas de otros métodos. Al menos funciona para lo que quiero:
require ''csv''
class MyCSV < CSV
def <<(row)
# make sure headers have been assigned
if header_row? and [Array, String].include? @use_headers.class
parse_headers # won''t read data for Array or String
self << @headers if @write_headers
end
# handle CSV::Row objects and Hashes
row = case row
when self.class::Row then row.fields
when Hash then @headers.map { |header| row[header] }
else row
end
@headers = row if header_row?
@lineno += 1
@do_quote ||= lambda do |field|
field = String(field)
encoded_quote = @quote_char.encode(field.encoding)
encoded_quote +
field.gsub(encoded_quote, encoded_quote * 2) +
encoded_quote
end
@quotable_chars ||= encode_str("/r/n", @col_sep, @quote_char)
@forced_quote_fields ||= []
@my_quote_lambda ||= lambda do |field, index|
if field.nil? # represent +nil+ fields as empty unquoted fields
""
else
field = String(field) # Stringify fields
# represent empty fields as empty quoted fields
if (
field.empty? or
field.count(@quotable_chars).nonzero? or
@forced_quote_fields.include?(index)
)
@do_quote.call(field)
else
field # unquoted field
end
end
end
output = row.map.with_index(&@my_quote_lambda).join(@col_sep) + @row_sep # quote and separate
if (
@io.is_a?(StringIO) and
output.encoding != raw_encoding and
(compatible_encoding = Encoding.compatible?(@io.string, output))
)
@io = StringIO.new(@io.string.force_encoding(compatible_encoding))
@io.seek(0, IO::SEEK_END)
end
@io << output
self # for chaining
end
alias_method :add_row, :<<
alias_method :puts, :<<
def forced_quote_fields=(indexes=[])
@forced_quote_fields = indexes
end
end
Ese es el código. Llamándolo:
data = [
%w[1 2 3],
[ 2, ''two too'', 3 ],
[ 3, ''two, too'', 3 ]
]
quote_fields = [1]
puts "Ruby version: #{ RUBY_VERSION }"
puts "Quoting fields: #{ quote_fields.join('', '') }", "/n"
csv = MyCSV.generate do |_csv|
_csv.forced_quote_fields = quote_fields
data.each do |d|
_csv << d
end
end
puts csv
resultados en:
# >> Ruby version: 1.9.2
# >> Quoting fields: 1
# >>
# >> 1,"2",3
# >> 2,"two too",3
# >> 3,"two, too",3
Dudo que esto ayude al cliente a sentirse cálido y confuso después de todo este tiempo, pero esto parece funcionar:
require ''csv''
#prepare a lambda which converts field with index 2
quote_col2 = lambda do |field, fieldinfo|
# fieldinfo has a line- ,header- and index-method
if fieldinfo.index == 2 && !field.start_with?(''"'') then
''"'' + field + ''"''
else
field
end
end
# specify above lambda as one of the converters
csv = CSV.read("test1.csv", :converters => [quote_col2])
p csv
# => [["aaa", "bbb", "/"ccc/"", "ddd"], ["fff", "ggg", "/"hhh/"", "iii"]]
File.open("test1.txt","w"){|out| csv.each{|line|out.puts line.join(",")}}
Esta publicación es antigua, pero no puedo creer que nadie haya pensado en esto.
Por que no hacer
csv = CSV.generate :quote_char => "/0" do |csv|
donde / 0 es un carácter nulo, luego simplemente agregue comillas a cada campo donde se necesiten:
csv << [product.upc, "/"" + product.name + "/"" # ...
Entonces al final puedes hacer una
csv.gsub!(//0/, '''')
No parece que haya ninguna manera de hacer esto con la implementación de CSV existente, a menos que sea parcheado / reescrito.
Sin embargo, suponiendo que tenga control total sobre los datos de origen, podría hacer esto:
- Agregue una cadena personalizada que incluya una coma (es decir, una que nunca se encontraría de forma natural en los datos) al final del campo en cuestión para cada fila; tal vez algo como " FORCE_COMMAS ".
- Generar la salida CSV.
- Ahora que tiene una salida CSV con comillas en cada fila para su campo, elimine la cadena personalizada:
csv.gsub!(/FORCE_COMMAS,/, "")
- El cliente se siente cálido y confuso.
CSV
tiene una opción force_quotes
que lo obligará a citar todos los campos (puede que no haya estado allí cuando publicaste esto originalmente). Me doy cuenta de que esto no es exactamente lo que propusiste, pero es menos parches de mono.
2.1.0 :008 > puts CSV.generate_line [1,''1.1.1.1'',''Firstname Lastname'',''more'',''fields'']
1,1.1.1.1,Firstname Lastname,more,fields
2.1.0 :009 > puts CSV.generate_line [1,''1.1.1.1'',''Firstname Lastname'',''more'',''fields''], force_quotes: true
"1","1.1.1.1","Firstname Lastname","more","fields"
El inconveniente es que el primer valor entero termina listado como una cadena, que cambia las cosas cuando se importa en Excel.